From df3ec2139f6b9172c2b5a8006a15a97cd4904ce7 Mon Sep 17 00:00:00 2001 From: "microsoft-github-policy-service[bot]" <77245923+microsoft-github-policy-service[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 03:58:13 +0000 Subject: [PATCH 01/15] Microsoft mandatory file --- SECURITY.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e751608 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ + + +## Security + +Microsoft takes the security of our software products and services seriously, which +includes all source code repositories in our GitHub organizations. + +**Please do not report security vulnerabilities through public GitHub issues.** + +For security reporting information, locations, contact information, and policies, +please review the latest guidance for Microsoft repositories at +[https://aka.ms/SECURITY.md](https://aka.ms/SECURITY.md). + + \ No newline at end of file From 7fbb121c249cc92283e4bc05cd8a6a99ea33a4ee Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Wed, 22 Apr 2026 11:33:47 +0200 Subject: [PATCH 02/15] Add unit tests and knowledge files for BC domain context - Introduced unit tests for the bc-domain-context implementation, covering various scenarios including filtering by application area, technology mismatches, layer precedence, and conditional applicability. - Added knowledge files related to finance, including topics such as Chart of Accounts, Codeunit 12, Dimension Management, and VAT on prepayment chains, among others. - Each knowledge file includes structured metadata and best practices to enhance the domain knowledge available for Business Central tasks. --- .github/scripts/bc_domain_context.py | 373 ++++++++++++++++++ .../scripts/tests/test_bc_domain_context.py | 297 ++++++++++++++ README.md | 4 +- .../knowledge/finance/chart-of-accounts.md | 28 ++ .../finance/codeunit-12-gen-jnl-post-line.md | 28 ++ .../codeunit-408-dimension-management.md | 28 ++ .../finance/dimension-combinations.md | 28 ++ .../finance/dimension-default-priority.md | 28 ++ microsoft/knowledge/finance/dimensions.md | 30 ++ .../knowledge/finance/entry-application.md | 30 ++ .../finance/exchange-rate-adjustment.md | 28 ++ .../finance/general-journal-posting.md | 28 ++ .../finance/general-ledger-entries.md | 28 ++ .../finance/multi-currency-rounding.md | 28 ++ microsoft/knowledge/finance/unrealized-vat.md | 28 ++ .../finance/vat-on-prepayment-chains.md | 28 ++ .../knowledge/finance/vat-posting-setup.md | 28 ++ microsoft/skills/bc-domain-context.md | 101 +++++ 18 files changed, 1170 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/bc_domain_context.py create mode 100644 .github/scripts/tests/test_bc_domain_context.py create mode 100644 microsoft/knowledge/finance/chart-of-accounts.md create mode 100644 microsoft/knowledge/finance/codeunit-12-gen-jnl-post-line.md create mode 100644 microsoft/knowledge/finance/codeunit-408-dimension-management.md create mode 100644 microsoft/knowledge/finance/dimension-combinations.md create mode 100644 microsoft/knowledge/finance/dimension-default-priority.md create mode 100644 microsoft/knowledge/finance/dimensions.md create mode 100644 microsoft/knowledge/finance/entry-application.md create mode 100644 microsoft/knowledge/finance/exchange-rate-adjustment.md create mode 100644 microsoft/knowledge/finance/general-journal-posting.md create mode 100644 microsoft/knowledge/finance/general-ledger-entries.md create mode 100644 microsoft/knowledge/finance/multi-currency-rounding.md create mode 100644 microsoft/knowledge/finance/unrealized-vat.md create mode 100644 microsoft/knowledge/finance/vat-on-prepayment-chains.md create mode 100644 microsoft/knowledge/finance/vat-posting-setup.md create mode 100644 microsoft/skills/bc-domain-context.md diff --git a/.github/scripts/bc_domain_context.py b/.github/scripts/bc_domain_context.py new file mode 100644 index 0000000..6bb6661 --- /dev/null +++ b/.github/scripts/bc_domain_context.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +""" +Reference implementation of the bc-domain-context action skill. + +Executes the skill's Source -> Relevance -> Worklist -> Action pipeline against +a BCQuality repository root and returns a findings-report dict conforming to +the DO output contract. + +This module is the *spec by example* for consumers that reimplement the skill +in other languages (for example, the Triage agent's Node.js client). Tests in +.github/scripts/tests/test_bc_domain_context.py exercise this implementation +against fixture knowledge trees. + +Usage (from Python): + from bc_domain_context import run_bc_domain_context + report = run_bc_domain_context(repo_root, task_context) + +Skill behaviour is defined in microsoft/skills/bc-domain-context.md; READ's +frontmatter-matching semantics live in skills/read.md. +""" +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable + +try: + import yaml +except ImportError: + sys.stderr.write("ERROR: PyYAML is required. Install with: pip install pyyaml\n") + sys.exit(2) + + +LAYERS = ("custom", "microsoft", "community") # highest precedence first +SKILL_ID = "bc-domain-context" +SKILL_VERSION = 1 + + +@dataclass +class KnowledgeFile: + path: str # repo-relative, forward slashes + layer: str # "microsoft" | "community" | "custom" + domain: str # folder name + slug: str # filename stem + frontmatter: dict[str, Any] + title: str # first H1 in body, or slug if absent + description: str # prose after "## Description", trimmed + + +# --- Frontmatter + body parsing --------------------------------------------- + +def _parse_markdown(text: str) -> tuple[dict[str, Any] | None, str]: + lines = text.splitlines() + if not lines or lines[0].rstrip() != "---": + return None, text + for i in range(1, len(lines)): + if lines[i].rstrip() == "---": + try: + fm = yaml.safe_load("\n".join(lines[1:i])) or {} + except yaml.YAMLError: + return None, text + if not isinstance(fm, dict): + return None, text + return fm, "\n".join(lines[i + 1:]) + return None, text + + +def _extract_title(body: str, fallback: str) -> str: + for line in body.splitlines(): + m = re.match(r"^#\s+(.+?)\s*$", line) + if m: + return m.group(1).strip() + return fallback + + +def _extract_description(body: str) -> str: + lines = body.splitlines() + for i, line in enumerate(lines): + if re.match(r"^##\s+Description\s*$", line): + buf: list[str] = [] + for j in range(i + 1, len(lines)): + if re.match(r"^##\s+", lines[j]): + break + buf.append(lines[j]) + return "\n".join(buf).strip() + return "" + + +# --- bc-version expansion (matches validate_frontmatter.expand_bc_version) -- + +_RANGE = re.compile(r"^(\d+)\.\.(\d+)$") + + +def _expand_bc_version(value: Any) -> list[int] | None: + if not isinstance(value, list) or not value: + return None + if all(isinstance(v, int) and not isinstance(v, bool) and v > 0 for v in value): + return sorted(set(value)) + if len(value) == 1 and isinstance(value[0], str): + m = _RANGE.match(value[0].strip()) + if m: + start, end = int(m.group(1)), int(m.group(2)) + if start <= end: + return list(range(start, end + 1)) + return None + + +# --- READ's frontmatter matching rules -------------------------------------- + +def _matches( + kf: KnowledgeFile, task_context: dict[str, Any] +) -> tuple[bool, list[str]]: + """Return (applicable, unknown_dimensions) per READ's semantics. + + A file is applicable when every rule matches. A rule is "unknown" when the + task context omits a dimension and the file does not declare a universal + sentinel for it; unknown rules do not disqualify the file but force a + medium-confidence ceiling on derived findings. + """ + unknown: list[str] = [] + fm = kf.frontmatter + + # bc-version + file_versions = _expand_bc_version(fm.get("bc-version")) + if file_versions is None: + return False, unknown + task_version = task_context.get("bc-version") + if task_version is None: + unknown.append("bc-version") + elif task_version not in file_versions: + return False, unknown + + # technologies (no sentinel) + file_techs = set(fm.get("technologies") or []) + task_techs = set(task_context.get("technologies") or []) + if not task_techs: + unknown.append("technologies") + elif not (file_techs & task_techs): + return False, unknown + + # countries (sentinel: w1) + file_countries = set(fm.get("countries") or []) + task_countries = set(task_context.get("countries") or []) + if "w1" in file_countries: + pass + elif not task_countries: + unknown.append("countries") + elif not (file_countries & task_countries): + return False, unknown + + # application-area (sentinel: all) + file_areas = set(fm.get("application-area") or []) + task_areas = set(task_context.get("application-area") or []) + if "all" in file_areas: + pass + elif not task_areas or "all" in task_areas: + # Empty or [all] in task means "any area"; file-level specific area + # still matches against any-area when sourced from the area folder. + pass + elif not (file_areas & task_areas): + return False, unknown + + return True, unknown + + +# --- Knowledge corpus load -------------------------------------------------- + +def _load_knowledge_file(root: Path, path: Path) -> KnowledgeFile | None: + rel = path.relative_to(root).as_posix() + parts = rel.split("/") + if len(parts) != 4 or parts[1] != "knowledge": + return None + layer, _, domain, filename = parts + slug = Path(filename).stem + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return None + fm, body = _parse_markdown(text) + if fm is None: + return None + return KnowledgeFile( + path=rel, + layer=layer, + domain=domain, + slug=slug, + frontmatter=fm, + title=_extract_title(body, slug), + description=_extract_description(body), + ) + + +def _load_corpus( + root: Path, enabled_layers: Iterable[str], areas: Iterable[str] | None +) -> list[KnowledgeFile]: + """Walk /knowledge//*.md for enabled layers. + + If `areas` is None or contains "all", walk every area folder. Otherwise, + walk only the named area folders. + """ + enabled = set(enabled_layers) & set(LAYERS) + area_set = None + if areas is not None: + areas_list = list(areas) + if areas_list and "all" not in areas_list: + area_set = set(areas_list) + corpus: list[KnowledgeFile] = [] + for layer in enabled: + knowledge_root = root / layer / "knowledge" + if not knowledge_root.is_dir(): + continue + for domain_dir in knowledge_root.iterdir(): + if not domain_dir.is_dir(): + continue + if area_set is not None and domain_dir.name not in area_set: + continue + for md_path in domain_dir.glob("*.md"): + kf = _load_knowledge_file(root, md_path) + if kf is not None: + corpus.append(kf) + return corpus + + +# --- Layer precedence ------------------------------------------------------- + +def _resolve_precedence( + files: list[KnowledgeFile], +) -> tuple[list[KnowledgeFile], list[dict[str, Any]]]: + """Keep the highest-precedence file per (domain, slug). Return (kept, suppressed). + + suppressed entries have shape { "reference": { "path": ... }, "reason": "layer-precedence" }. + """ + precedence = {layer: i for i, layer in enumerate(LAYERS)} # lower index = higher precedence + groups: dict[tuple[str, str], list[KnowledgeFile]] = {} + for f in files: + groups.setdefault((f.domain, f.slug), []).append(f) + kept: list[KnowledgeFile] = [] + suppressed: list[dict[str, Any]] = [] + for group in groups.values(): + group.sort(key=lambda f: precedence.get(f.layer, 99)) + kept.append(group[0]) + for loser in group[1:]: + suppressed.append({ + "reference": {"path": loser.path}, + "reason": "layer-precedence", + }) + return kept, suppressed + + +# --- Worklist narrowing ----------------------------------------------------- + +_GOAL_PREFIX = re.compile(r"^bc-domain-context(?:\s+for\s+[a-z0-9,\- ]+)?\s*", re.IGNORECASE) +_TOKEN = re.compile(r"[a-z0-9]+") + + +def _extract_goal_tokens(goal: str) -> list[str]: + """Strip the 'bc-domain-context for ' prefix and return significant tokens.""" + if not goal: + return [] + stripped = _GOAL_PREFIX.sub("", goal).strip() + if not stripped: + return [] + return [t for t in _TOKEN.findall(stripped.lower()) if len(t) >= 3] + + +def _score(kf: KnowledgeFile, tokens: list[str]) -> int: + if not tokens: + return 0 + kw = {k.lower() for k in (kf.frontmatter.get("keywords") or [])} + text = f"{kf.slug} {kf.title} {kf.description}".lower() + score = 0 + for t in tokens: + if t in kw: + score += 3 + if t in text: + score += 1 + return score + + +def _narrow( + kept: list[KnowledgeFile], task_context: dict[str, Any], max_top: int = 15 +) -> list[KnowledgeFile]: + tokens = _extract_goal_tokens(task_context.get("goal") or "") + if not tokens: + return kept + scored = [(kf, _score(kf, tokens)) for kf in kept] + scored.sort(key=lambda p: (-p[1], p[0].path)) + # Keep only files with positive score; fall back to full set if none score. + positive = [kf for kf, s in scored if s > 0] + if not positive: + return kept + return positive[:max_top] + + +# --- Message construction --------------------------------------------------- + +def _message(kf: KnowledgeFile, unknown: list[str]) -> str: + first_sentences = re.split(r"(?<=[.!?])\s+", kf.description, maxsplit=3) + lead = " ".join(first_sentences[:3]).strip() if first_sentences else "" + msg = f"{kf.title}. {lead}" if lead else kf.title + if unknown: + msg += f" (conditional on: {', '.join(sorted(unknown))})" + return msg + + +# --- Main entrypoint -------------------------------------------------------- + +def run_bc_domain_context( + root: Path | str, task_context: dict[str, Any] +) -> dict[str, Any]: + """Execute the skill against the repository at `root`. + + Returns a findings-report dict conforming to the DO output contract. + """ + root = Path(root) + enabled = task_context.get("enabled-layers") or list(LAYERS) + areas = task_context.get("application-area") + + corpus = _load_corpus(root, enabled, areas) + if not corpus: + return _report("no-knowledge", [], []) + + applicable: list[tuple[KnowledgeFile, list[str]]] = [] + for kf in corpus: + ok, unknown = _matches(kf, task_context) + if ok: + applicable.append((kf, unknown)) + if not applicable: + return _report("not-applicable", [], []) + + kept, suppressed = _resolve_precedence([kf for kf, _ in applicable]) + kept_set = {kf.path for kf in kept} + unknown_by_path = {kf.path: u for kf, u in applicable if kf.path in kept_set} + + worklist = _narrow(kept, task_context) + + findings: list[dict[str, Any]] = [] + for kf in worklist: + unknown = unknown_by_path.get(kf.path, []) + findings.append({ + "id": kf.path, + "severity": "info", + "message": _message(kf, unknown), + "references": [{"path": kf.path}], + "confidence": "medium" if unknown else "high", + }) + + return _report("completed", findings, suppressed) + + +def _report( + outcome: str, findings: list[dict[str, Any]], suppressed: list[dict[str, Any]] +) -> dict[str, Any]: + return { + "skill": {"id": SKILL_ID, "version": SKILL_VERSION}, + "outcome": outcome, + "summary": { + "counts": { + "blocker": 0, + "major": 0, + "minor": 0, + "info": len(findings), + }, + "coverage": { + "worklist-size": len(findings), + "items-evaluated": len(findings), + }, + }, + "findings": findings, + "suppressed": suppressed, + } diff --git a/.github/scripts/tests/test_bc_domain_context.py b/.github/scripts/tests/test_bc_domain_context.py new file mode 100644 index 0000000..b4a072e --- /dev/null +++ b/.github/scripts/tests/test_bc_domain_context.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +""" +Tests for the bc-domain-context reference implementation. + +Run with: python -m unittest .github.scripts.tests.test_bc_domain_context +Or: cd .github/scripts/tests && python -m unittest test_bc_domain_context + +The tests build fixture knowledge trees in tempfile directories and exercise +the skill's Source / Relevance / Worklist / Action pipeline end to end. +""" +from __future__ import annotations + +import os +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path + +# Add the scripts directory (parent of tests/) to sys.path so we can import +# bc_domain_context.py. +_SCRIPTS_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_SCRIPTS_DIR)) + +from bc_domain_context import run_bc_domain_context # noqa: E402 + + +def _write( + root: Path, + layer: str, + domain: str, + slug: str, + *, + bc_version: str = "[26..28]", + technologies: str = "[al]", + countries: str = "[w1]", + application_area: str | None = None, + keywords: str = "[sample]", + domain_value: str | None = None, + description: str = "Short description. Another sentence.", + title: str | None = None, +) -> Path: + """Write a fixture knowledge file and return its path.""" + if application_area is None: + application_area = f"[{domain}]" + if domain_value is None: + domain_value = domain + if title is None: + title = slug.replace("-", " ").capitalize() + target = root / layer / "knowledge" / domain / f"{slug}.md" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + textwrap.dedent( + f"""\ + --- + bc-version: {bc_version} + domain: {domain_value} + keywords: {keywords} + technologies: {technologies} + countries: {countries} + application-area: {application_area} + --- + + # {title} + + ## Description + + {description} + """ + ), + encoding="utf-8", + ) + return target + + +class BcDomainContextTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self.addCleanup(self._tmp.cleanup) + + # --- Core filtering --- + + def test_filter_by_area_returns_only_requested_area(self): + _write(self.root, "microsoft", "finance", "chart-of-accounts") + _write(self.root, "microsoft", "finance", "general-ledger-entries") + _write(self.root, "microsoft", "finance", "dimensions") + _write(self.root, "microsoft", "sales", "order-to-cash") + _write(self.root, "microsoft", "sales", "pricing") + + report = run_bc_domain_context(self.root, { + "application-area": ["finance"], + "technologies": ["al"], + "bc-version": 28, + "countries": ["w1"], + }) + + self.assertEqual(report["outcome"], "completed") + paths = sorted(f["id"] for f in report["findings"]) + self.assertEqual(paths, [ + "microsoft/knowledge/finance/chart-of-accounts.md", + "microsoft/knowledge/finance/dimensions.md", + "microsoft/knowledge/finance/general-ledger-entries.md", + ]) + for finding in report["findings"]: + self.assertEqual(finding["severity"], "info") + self.assertEqual(finding["confidence"], "high") + + def test_application_area_all_returns_union(self): + _write(self.root, "microsoft", "finance", "chart-of-accounts") + _write(self.root, "microsoft", "sales", "order-to-cash") + _write(self.root, "microsoft", "manufacturing", "bom") + + report = run_bc_domain_context(self.root, { + "application-area": ["all"], + "technologies": ["al"], + "bc-version": 28, + "countries": ["w1"], + }) + + self.assertEqual(report["outcome"], "completed") + self.assertEqual(len(report["findings"]), 3) + paths = {f["id"] for f in report["findings"]} + self.assertIn("microsoft/knowledge/finance/chart-of-accounts.md", paths) + self.assertIn("microsoft/knowledge/sales/order-to-cash.md", paths) + self.assertIn("microsoft/knowledge/manufacturing/bom.md", paths) + + def test_technologies_mismatch_drops_file(self): + _write(self.root, "microsoft", "finance", "al-concept") + _write( + self.root, + "microsoft", + "finance", + "kql-only-concept", + technologies="[kql]", + ) + + report = run_bc_domain_context(self.root, { + "application-area": ["finance"], + "technologies": ["al"], + "bc-version": 28, + "countries": ["w1"], + }) + + paths = {f["id"] for f in report["findings"]} + self.assertIn("microsoft/knowledge/finance/al-concept.md", paths) + self.assertNotIn("microsoft/knowledge/finance/kql-only-concept.md", paths) + + def test_no_matching_knowledge_returns_not_applicable(self): + _write(self.root, "microsoft", "finance", "chart-of-accounts") + + report = run_bc_domain_context(self.root, { + "application-area": ["manufacturing"], + "technologies": ["al"], + "bc-version": 28, + "countries": ["w1"], + }) + + self.assertEqual(report["outcome"], "no-knowledge") + self.assertEqual(report["findings"], []) + + # --- Layer precedence --- + + def test_layer_precedence_microsoft_wins_over_community(self): + _write(self.root, "community", "finance", "vat-on-prepayment") + _write(self.root, "microsoft", "finance", "vat-on-prepayment") + + report = run_bc_domain_context(self.root, { + "application-area": ["finance"], + "technologies": ["al"], + "bc-version": 28, + "countries": ["w1"], + }) + + paths = [f["id"] for f in report["findings"]] + self.assertEqual( + paths, ["microsoft/knowledge/finance/vat-on-prepayment.md"] + ) + suppressed_paths = [s["reference"]["path"] for s in report["suppressed"]] + self.assertEqual( + suppressed_paths, + ["community/knowledge/finance/vat-on-prepayment.md"], + ) + self.assertEqual(report["suppressed"][0]["reason"], "layer-precedence") + + def test_layer_precedence_custom_wins_over_microsoft(self): + _write(self.root, "microsoft", "finance", "vat-on-prepayment") + _write(self.root, "custom", "finance", "vat-on-prepayment") + + report = run_bc_domain_context(self.root, { + "application-area": ["finance"], + "technologies": ["al"], + "bc-version": 28, + "countries": ["w1"], + }) + + paths = [f["id"] for f in report["findings"]] + self.assertEqual( + paths, ["custom/knowledge/finance/vat-on-prepayment.md"] + ) + + # --- Conditional applicability (unknown dimensions) --- + + def test_unknown_bc_version_caps_confidence_at_medium(self): + _write(self.root, "microsoft", "finance", "chart-of-accounts") + _write(self.root, "microsoft", "finance", "dimensions") + + # bc-version omitted from task-context. + report = run_bc_domain_context(self.root, { + "application-area": ["finance"], + "technologies": ["al"], + "countries": ["w1"], + }) + + self.assertTrue(report["findings"]) + for finding in report["findings"]: + self.assertEqual(finding["confidence"], "medium") + self.assertIn("bc-version", finding["message"]) + + # --- Goal-directed narrowing --- + + def test_goal_tokens_narrow_worklist(self): + _write( + self.root, + "microsoft", + "finance", + "vat-on-prepayment-chains", + keywords="[vat, prepayment, credit-memo]", + ) + _write( + self.root, + "microsoft", + "finance", + "dimensions", + keywords="[dimensions, default-priority]", + ) + _write( + self.root, + "microsoft", + "finance", + "chart-of-accounts", + keywords="[chart, account]", + ) + + report = run_bc_domain_context(self.root, { + "goal": "bc-domain-context for finance — VAT wrong on prepayment credit memo", + "application-area": ["finance"], + "technologies": ["al"], + "bc-version": 28, + "countries": ["w1"], + }) + + paths = [f["id"] for f in report["findings"]] + self.assertIn( + "microsoft/knowledge/finance/vat-on-prepayment-chains.md", + paths, + ) + # The highest-scoring file should come first. + self.assertEqual( + paths[0], + "microsoft/knowledge/finance/vat-on-prepayment-chains.md", + ) + + def test_generic_goal_keeps_full_area(self): + _write(self.root, "microsoft", "finance", "chart-of-accounts") + _write(self.root, "microsoft", "finance", "dimensions") + + report = run_bc_domain_context(self.root, { + "goal": "bc-domain-context for finance", + "application-area": ["finance"], + "technologies": ["al"], + "bc-version": 28, + "countries": ["w1"], + }) + + self.assertEqual(len(report["findings"]), 2) + + # --- Layer disabling --- + + def test_disabled_layer_is_invisible(self): + _write(self.root, "community", "finance", "only-in-community") + _write(self.root, "microsoft", "finance", "only-in-microsoft") + + report = run_bc_domain_context(self.root, { + "application-area": ["finance"], + "technologies": ["al"], + "bc-version": 28, + "countries": ["w1"], + "enabled-layers": ["microsoft"], + }) + + paths = [f["id"] for f in report["findings"]] + self.assertEqual(paths, ["microsoft/knowledge/finance/only-in-microsoft.md"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/README.md b/README.md index ed222d7..f9c86cc 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Every knowledge file is a markdown file with mandatory YAML frontmatter. Files t ```yaml --- -bc-version: [26..28] # BC versions this applies to +bc-version: [26..28] # BC versions this applies to; use [1..99] for universal content domain: performance # security | performance | ux | telemetry | ... keywords: [query, filtering, partial] # free-text tags for retrieval technologies: [al] # al | javascript | powershell | ... @@ -63,6 +63,8 @@ application-area: [all] # finance | manufacturing | jobs | [all] All six fields are required. The schema is locked — changes require a PR approved by both maintainers. +**`bc-version` convention.** Content that describes version-specific behaviour (a query optimizer change in BC 26, a security API renamed in BC 27) lists the specific major versions or a tight range. Content that describes canonical BC concepts which are stable across every supported release — domain knowledge like `Codeunit 12 (Gen. Jnl.-Post Line)`, `T15 G/L Account`, VAT posting setup, the dimensions model — uses `bc-version: [1..99]`. This signals "applies to every BC version past, present, and future" without forcing an annual bump of every file when a new release ships. + ### Sections Every knowledge file must contain a `## Description` section. The following sections are optional but recommended: diff --git a/microsoft/knowledge/finance/chart-of-accounts.md b/microsoft/knowledge/finance/chart-of-accounts.md new file mode 100644 index 0000000..1255838 --- /dev/null +++ b/microsoft/knowledge/finance/chart-of-accounts.md @@ -0,0 +1,28 @@ +--- +bc-version: [1..99] +domain: finance +keywords: [chart-of-accounts, gl-account, account-category, account-subcategory, financial-reports] +technologies: [al] +countries: [w1] +application-area: [finance] +--- + +# Chart of Accounts + +## Description + +The Chart of Accounts (table 15 — `G/L Account`) is the backbone of Business Central's financial reporting. Every posting in the system, no matter where it originates, eventually produces G/L Entries against accounts defined here. The chart's structure determines what financial statements look like: accounts carry an Account Category (Assets, Liabilities, Equity, Income, Cost of Goods Sold, Expense) and an Account Subcategory that groups them for statement rows. Financial reports (balance sheet, income statement, trial balance) aggregate entries by these classifications rather than by the raw account numbers. + +Because every sub-ledger (customer, vendor, item, fixed asset, bank) ultimately posts to G/L, the chart is the single integration point for all monetary movement. A miscategorised account shifts amounts between sections of the financial statements without producing a posting error — the numbers look fine at the account level and wrong at the statement level. + +## Best Practice + +Set Account Category and Account Subcategory on every G/L Account — do not leave them blank on new accounts. Run the financial report rebuild after restructuring the chart so the subcategory totals re-calculate against all historical entries. + +## Anti Pattern + +Using free-text Account Name as the only grouping signal. Reports that aggregate by name are brittle to typos and translation; category/subcategory are the authoritative grouping. + +## Provenance + +Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Chart of Accounts & G/L Posting") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Finance\`. diff --git a/microsoft/knowledge/finance/codeunit-12-gen-jnl-post-line.md b/microsoft/knowledge/finance/codeunit-12-gen-jnl-post-line.md new file mode 100644 index 0000000..55f3e02 --- /dev/null +++ b/microsoft/knowledge/finance/codeunit-12-gen-jnl-post-line.md @@ -0,0 +1,28 @@ +--- +bc-version: [1..99] +domain: finance +keywords: [codeunit-12, gen-jnl-post-line, journal-posting, high-risk, ledger-integrity] +technologies: [al] +countries: [w1] +application-area: [finance] +--- + +# Codeunit 12 (Gen. Jnl.-Post Line) + +## Description + +Codeunit 12 is the single posting engine for every journal line in Business Central. General journals, payment journals, cash receipt journals, recurring journals, IC journals, and the journal-like intermediaries used by document posting (Sales-Post, Purch.-Post, Invoice Post. Buffer) all funnel through this codeunit to produce G/L Entries, Customer Ledger Entries, Vendor Ledger Entries, Bank Account Ledger Entries, VAT Entries, and Detailed Ledger Entries. The entry numbering, dimension resolution, multi-currency math, and VAT computation for every posted line happen here. + +Because every monetary posting passes through codeunit 12, any modification to its behaviour — even a seemingly local change to one sub-procedure — has repository-wide blast radius. A change intended to affect only Purchase posting will also hit Sales, General Journal, Intercompany, bank payments, and every extension that raises integration events on codeunit 12's publishers. Debugging an unexpected posting change across multiple modules frequently traces back here. + +## Best Practice + +Extend codeunit 12 only through the published integration events (`OnAfterPostGLAcc`, `OnAfterPostCustVendAccount`, etc.). Subscribing is additive and local to the subscriber; forking codeunit 12's body inside an extension loses access to Microsoft's future fixes. + +## Anti Pattern + +Modifying codeunit 12's core computation in a customisation to fix a reported issue. Every subsequent BC platform upgrade must merge around the modification, and the modification's side effects on other modules are rarely exhaustively tested. Use events. + +## Provenance + +Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "High-Risk Areas") on 2026-04-21. diff --git a/microsoft/knowledge/finance/codeunit-408-dimension-management.md b/microsoft/knowledge/finance/codeunit-408-dimension-management.md new file mode 100644 index 0000000..31cad48 --- /dev/null +++ b/microsoft/knowledge/finance/codeunit-408-dimension-management.md @@ -0,0 +1,28 @@ +--- +bc-version: [1..99] +domain: finance +keywords: [codeunit-408, dimension-management, dimension-merge, high-risk, dimension-set] +technologies: [al] +countries: [w1] +application-area: [finance] +--- + +# Codeunit 408 (Dimension Management) + +## Description + +Codeunit 408 is the central broker for every dimension operation in Business Central. It resolves Default Dimensions into concrete Dimension Set Entries, deduplicates sets by Dimension Set ID, merges header/line/master-data defaults during posting, enforces Dimension Combination rules, and owns the API that every other module uses to read or write dimensions. Sales, Purchase, Manufacturing, Warehouse, Fixed Assets, and Job posting all call into this codeunit; the codeunit is also how BC-internal UI controls retrieve the dimension values shown on a document. + +A modification here propagates to every posted dimension, across every module. The blast radius is not bounded by "we only customised Sales" — a change to the merge logic that Sales happens to exercise may shift dimensions on a Job Journal that shares no code with Sales. Subscribers to codeunit 408's integration events are safe; direct modifications are a high-risk change that frequently produces silent drift (dimension values on ledger entries that are defensible line by line but produce wrong totals in the financial statements). + +## Best Practice + +Extend only via the published integration events or by subscribing to business-layer events that codeunit 408 emits during its lifecycle. Review every dimension-related extension as part of every BC upgrade to confirm its event subscribers still fire. + +## Anti Pattern + +Writing to Dimension Set Entries (table 480) from custom code to "fix" a miscategorised entry. The set is shared across many entries; editing the set retroactively reclassifies every entry that referenced it, usually in ways the author did not intend. + +## Provenance + +Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "High-Risk Areas") on 2026-04-21. diff --git a/microsoft/knowledge/finance/dimension-combinations.md b/microsoft/knowledge/finance/dimension-combinations.md new file mode 100644 index 0000000..008911e --- /dev/null +++ b/microsoft/knowledge/finance/dimension-combinations.md @@ -0,0 +1,28 @@ +--- +bc-version: [1..99] +domain: finance +keywords: [dimension-combination, blocked-combination, dimension-matrix, dimension-value-combination] +technologies: [al] +countries: [w1] +application-area: [finance] +--- + +# Dimension combinations + +## Description + +Dimension Combinations (tables 350 — `Dimension Combination`, and 351 — `Dimension Value Combination`) are the guardrail that restricts which dimension values may coexist on the same posting. Table 350 records pair-level rules for two dimensions (typically Global Dimension 1 and Global Dimension 2): the pair may be Blocked, Limited (only specific value pairs allowed), or blank (free). Table 351 records the allowed value pairs under a Limited combination. The check runs during posting via codeunit 408; when a rejected pair arrives on a Dimension Set, posting fails with a specific error naming the blocked combination. + +Combinations are the right mechanism for organisational rules like "the Marketing department cannot post to the Factory location" — they enforce once, at post time, across every document type. They are the wrong mechanism for user-input validation (they do not fire until post), and they are often surprising to users who see them for the first time years into a deployment because they were set up once and forgotten. + +## Best Practice + +When introducing a new blocked combination, run a what-if query against open documents and journal batches first. Existing lines whose dimensions already violate the new rule will fail posting as soon as the rule activates; fix those lines before turning it on. + +## Anti Pattern + +Using Dimension Combinations to simulate permission checks. They gate posting, not data entry, and they fire in every module — a combination added to enforce a Sales workflow may suddenly block a General Journal entry no one expected. + +## Provenance + +Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Dimensions — The #1 Source of Finance Issues") on 2026-04-21. diff --git a/microsoft/knowledge/finance/dimension-default-priority.md b/microsoft/knowledge/finance/dimension-default-priority.md new file mode 100644 index 0000000..79df955 --- /dev/null +++ b/microsoft/knowledge/finance/dimension-default-priority.md @@ -0,0 +1,28 @@ +--- +bc-version: [1..99] +domain: finance +keywords: [dimension-default, dimension-priority, posting-conflict, dimension-merge, troubleshooting] +technologies: [al] +countries: [w1] +application-area: [finance] +--- + +# Dimension default priority + +## Description + +At post time, Business Central merges dimension values from several sources into the final Dimension Set for each ledger entry. The merge honours a priority order: document header defaults (copied from customer/vendor at document creation) are the base, document line defaults overlay header (customer/vendor/item/G/L account defaults applied line by line), and line-level user edits overlay the defaults. For Gen. Journal posting, G/L Account default dimensions are applied inside codeunit 12 after the line is otherwise finalised. Dimension Combinations (tables 350/351) are a final gate that may reject the merged set outright. + +The single most common posting error in finance is a conflict during this merge: a line-default mandatory dimension conflicts with a header-default Same Code rule, or a Dimension Combination rejects a pair that neither source knew about. Users see the error only at post time, often long after the values were set. Troubleshooting requires tracing back through each source layer. + +## Best Practice + +When a post fails on dimensions, inspect in this order: (1) the error message's named conflict, (2) Default Dimensions on every master referenced by the document (customer, vendor, items, G/L accounts), (3) the Dimension Combination matrix, (4) document-header dimensions for staleness (a header-level change does NOT cascade to existing lines; if the header was re-coded after lines were entered, line defaults no longer match). + +## Anti Pattern + +Manually forcing a Dimension Set ID on a ledger entry to bypass the merge. The entry then carries a set that does not match its source defaults, and next-period reports silently disagree with the journal audit trail. + +## Provenance + +Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (sections: "Dimensions — The #1 Source of Finance Issues", "Dimension Conflict Troubleshooting") on 2026-04-21. diff --git a/microsoft/knowledge/finance/dimensions.md b/microsoft/knowledge/finance/dimensions.md new file mode 100644 index 0000000..362f4d3 --- /dev/null +++ b/microsoft/knowledge/finance/dimensions.md @@ -0,0 +1,30 @@ +--- +bc-version: [1..99] +domain: finance +keywords: [dimensions, default-dimension, dimension-set, codeunit-408, global-dimension] +technologies: [al] +countries: [w1] +application-area: [finance] +--- + +# Dimensions + +## Description + +Dimensions are analytical tags — Department, Project, Region, etc. — attached to every posting so financial reports can filter and group by business attributes without adding columns to every ledger table. Two representations coexist: Default Dimensions (table 352) declare per-record defaults on masters (customer, vendor, item, G/L account, employee), and Dimension Set Entries (table 480) record the actual combinations carried on each ledger entry. A Dimension Set ID on a ledger entry references the exact set of dimension values; multiple entries sharing a set reuse the same ID rather than duplicating rows. + +Codeunit 408 (`Dimension Management`) is the central broker: it resolves defaults into concrete sets at posting time, deduplicates sets, and enforces dimension combination rules. It also owns the merge logic that walks document header, document line, and master-data defaults to produce the final set. + +Default Dimensions carry one of four rules per dimension: Code Mandatory (posting blocks without a value), Same Code (the value must match the master's default exactly), No Code (posting blocks if any value is provided), or blank (free choice, no constraint). The rule is enforced at post time, not at entry; user-interface entry may allow setting values that later fail posting. + +## Best Practice + +Set Global Dimension 1/2 on every master that drives dimension analysis; this prepopulates document lines without users remembering to add them. + +## Anti Pattern + +Introducing a new required dimension mid-year without backfilling existing open documents. Posting will fail for every document whose header was created before the rule existed. + +## Provenance + +Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Dimensions — The #1 Source of Finance Issues") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Foundation\Dimensions\`. diff --git a/microsoft/knowledge/finance/entry-application.md b/microsoft/knowledge/finance/entry-application.md new file mode 100644 index 0000000..90bf0a4 --- /dev/null +++ b/microsoft/knowledge/finance/entry-application.md @@ -0,0 +1,30 @@ +--- +bc-version: [1..99] +domain: finance +keywords: [entry-application, remaining-amount, payment-discount, payment-tolerance, codeunit-226, codeunit-227] +technologies: [al] +countries: [w1] +application-area: [finance] +--- + +# Entry application + +## Description + +Entry application is the mechanism by which payments close invoices, credit memos offset invoices, and refunds close credits. A customer ledger entry carries a Remaining Amount that tracks the unapplied balance; an application event (Apply Customer Entries / Apply Vendor Entries) reduces Remaining Amount on both sides of the application until one side hits zero. Codeunit 226 (`CustEntry-Apply Posted Entries`) handles customer applications; codeunit 227 (`VendEntry-Apply Posted Entries`) handles vendors. Both route through codeunit 12 for the G/L posting and write Detailed Cust./Vendor Ledger Entries that preserve the application history. + +Two tolerances add flexibility. Payment Discount gives a counterparty a reduced amount if they pay within a grace window; when the payment matches the discounted amount, the invoice closes and a discount-expense G/L Entry records the difference. Payment Tolerance lets a slightly short payment still close an invoice; the shortfall posts to a Payment Tolerance account. Both are configured on the Sales & Receivables Setup and Vendor Posting Groups; both can be disabled per customer/vendor. + +Applications across currencies trigger an exchange-rate adjustment inside the application itself: the FCY amounts apply directly, but the LCY equivalent of each leg may differ because the rates at posting dates differ. The difference posts to an exchange gain/loss account as part of the application, independent of the period-end Adjust Exchange Rates batch. + +## Best Practice + +Let the Apply action compute the amounts. Manually setting Amount to Apply on one side and letting the other auto-calculate produces rounding that can leave tiny (0.01) Remaining Amounts that block closing the period. + +## Anti Pattern + +Scripting direct updates to Remaining Amount to close out a balance. The Detailed Cust./Vendor Ledger Entry chain no longer matches and the entry, while appearing closed, cannot be un-applied or reversed cleanly. + +## Provenance + +Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Entry Application") on 2026-04-21. diff --git a/microsoft/knowledge/finance/exchange-rate-adjustment.md b/microsoft/knowledge/finance/exchange-rate-adjustment.md new file mode 100644 index 0000000..610b672 --- /dev/null +++ b/microsoft/knowledge/finance/exchange-rate-adjustment.md @@ -0,0 +1,28 @@ +--- +bc-version: [1..99] +domain: finance +keywords: [exchange-rate, adjust-exchange-rate, report-595, detailed-ledger-entry, unrealized-gain-loss] +technologies: [al] +countries: [w1] +application-area: [finance] +--- + +# Exchange rate adjustment + +## Description + +Open foreign-currency ledger entries accumulate unrealized gain or loss as the exchange rate drifts from the posting-date rate. Report 595 (`Adjust Exchange Rates`) is the period-end batch job that revalues every open customer, vendor, bank, and G/L entry against the rate at the adjustment date. For each entry, it computes the rate delta, posts a Detailed Cust./Vendor Ledger Entry (or G/L Entry for bank and G/L accounts) that brings the LCY value back in line, and posts the offset to the configured Unrealized Gains/Unrealized Losses account. The next run reverses the prior adjustment before posting a new one, so the unrealized accounts only ever carry the current-period difference. + +Running this batch is the hinge between period-end reporting and correct FCY balances. Skipping a period leaves the LCY equivalent of open balances stale; the next run has to absorb two periods of drift into one, producing a large unrealized swing that auditors flag. Running it twice in the same period on the same data is safe — the reversal mechanism makes the operation idempotent as long as the rate table has not changed. + +## Best Practice + +Schedule the batch as part of the month-end close, after posting the last FCY transactions and before freezing the period. Store the Exchange Rate table values the batch used so that re-running against "today's rates" later can be reconciled against the month-end snapshot. + +## Anti Pattern + +Running the batch without first verifying that the Currency Exchange Rate table has entries for the adjustment date. BC silently uses the most recent earlier entry, which on a missing-rate day can be weeks stale and produces an unrealized swing with no economic meaning. + +## Provenance + +Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Multi-Currency — Rounding and Exchange Rates") on 2026-04-21. diff --git a/microsoft/knowledge/finance/general-journal-posting.md b/microsoft/knowledge/finance/general-journal-posting.md new file mode 100644 index 0000000..0cd139a --- /dev/null +++ b/microsoft/knowledge/finance/general-journal-posting.md @@ -0,0 +1,28 @@ +--- +bc-version: [1..99] +domain: finance +keywords: [general-journal, journal-line, journal-posting, journal-types, gen-jnl-post-line] +technologies: [al] +countries: [w1] +application-area: [finance] +--- + +# General journal posting + +## Description + +Journal posting is the freeform pathway into G/L. A user fills lines in a journal batch (table 81 — `Gen. Journal Line`) and runs post; codeunit 12 (`Gen. Jnl.-Post Line`) processes each line into the appropriate ledger entries. The same table serves several journal types, distinguished by their template/batch combination: General (generic postings), Payment (outgoing payments with applying logic), Cash Receipt (incoming payments with applying logic), Recurring (allocations and accruals with date formulas), and IC General (intercompany variants that replicate to partner companies). + +Before the post, codeunit 13 (`Gen. Jnl.-Check Line`) validates each line — balancing, dimensions, account existence, posting restrictions. A failure there halts the entire batch; partial posts are not possible within a balanced transaction set. A single journal batch may contain many balanced transactions; each transaction is identified by a matching Document No. and must balance to zero across debits and credits. + +## Best Practice + +Separate unrelated postings into distinct balanced transactions (distinct Document No. values) within the batch. This makes a failure easier to locate and lets un-failed transactions still post if the check is run line-by-line. + +## Anti Pattern + +Stacking many unrelated postings under one Document No. A single validation error then blocks everything and the user must hunt for the offending line inside the balanced group. + +## Provenance + +Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Chart of Accounts & G/L Posting") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Finance\`. diff --git a/microsoft/knowledge/finance/general-ledger-entries.md b/microsoft/knowledge/finance/general-ledger-entries.md new file mode 100644 index 0000000..6361616 --- /dev/null +++ b/microsoft/knowledge/finance/general-ledger-entries.md @@ -0,0 +1,28 @@ +--- +bc-version: [1..99] +domain: finance +keywords: [gl-entry, ledger, posting, subledger, immutable] +technologies: [al] +countries: [w1] +application-area: [finance] +--- + +# General Ledger entries + +## Description + +G/L Entries (table 17) are the ultimate destination of every monetary posting in Business Central. Every sub-ledger entry — Customer Ledger Entry (21), Vendor Ledger Entry (25), Item Ledger Entry (32), Fixed Asset Ledger Entry (5601), Bank Account Ledger Entry (271) — produces corresponding G/L Entries that update account balances. The sub-ledgers exist to carry dimension-specific analytical data (due date, item number, reservation); G/L Entries are the canonical financial record. + +G/L Entries are immutable. Reversing a mistake requires a corrective posting (often via `Reverse` on the original entry), not modification. The Entry No. column is monotonically increasing, so applications ordering entries by Entry No. see insertion order; they should not assume any relationship between Entry No. and Posting Date. + +## Best Practice + +When reading G/L Entries in a report, filter on Posting Date and Global Dimension 1/2 Code rather than on numeric Entry No. ranges — ranges are not stable across companies and break when entries are reversed and re-posted. + +## Anti Pattern + +Modifying G/L Entry columns directly in custom code to "fix" a posting error. The sub-ledger entries and supporting tables (Detailed Cust./Vendor Ledger Entry, VAT Entry) remain unchanged and diverge from G/L, producing an off-balance state that only surfaces at period-close reconciliation. + +## Provenance + +Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Chart of Accounts & G/L Posting") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Finance\`. diff --git a/microsoft/knowledge/finance/multi-currency-rounding.md b/microsoft/knowledge/finance/multi-currency-rounding.md new file mode 100644 index 0000000..a0de878 --- /dev/null +++ b/microsoft/knowledge/finance/multi-currency-rounding.md @@ -0,0 +1,28 @@ +--- +bc-version: [1..99] +domain: finance +keywords: [multi-currency, rounding, currency-precision, invoice-rounding, fcy-lcy] +technologies: [al] +countries: [w1] +application-area: [finance] +--- + +# Multi-currency rounding + +## Description + +Every Currency record (table 4) declares four rounding precisions that govern how BC handles foreign-currency (FCY) amounts: Amount Rounding Precision (typically 0.01), Unit-Amount Rounding Precision (typically 0.00001, used for unit prices), Invoice Rounding Precision (typically 0.01, the tolerance that lets an invoice round to a "clean" final amount), and Appln. Rounding Precision (tolerance for closing applications across currencies). The four precisions interact with the local currency's precision to determine what amounts a document ends up posting. + +At post time the codeunit computes three amounts per line: the FCY amount (rounded to Amount Rounding Precision), the LCY amount (FCY × exchange rate, rounded to the local currency's precision), and any residual that falls to the Invoice Rounding account configured on the Customer/Vendor Posting Group. Rounding conflicts appear when: (a) LCY precision is coarser than FCY — JPY bookkeeping with EUR documents rounds to whole yen but allows 0.01 EUR; (b) per-line rounding on a many-line document diverges from single-line rounding of the document total; (c) the exchange rate changes between an order's receipt and its invoice, and the rounding residual shifts. + +## Best Practice + +Use the Invoice Rounding account purposefully — set it to a dedicated G/L account so the residuals aggregate where finance can review them. A catch-all "other income" lumps them with real transactions and hides rounding drift. + +## Anti Pattern + +Disabling Invoice Rounding by setting the precision to 0. The residuals then split across every VAT and payment account, making period-end reconciliation a hunt for pennies that do not belong to any transaction. + +## Provenance + +Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Multi-Currency — Rounding and Exchange Rates") on 2026-04-21. diff --git a/microsoft/knowledge/finance/unrealized-vat.md b/microsoft/knowledge/finance/unrealized-vat.md new file mode 100644 index 0000000..57cf7e9 --- /dev/null +++ b/microsoft/knowledge/finance/unrealized-vat.md @@ -0,0 +1,28 @@ +--- +bc-version: [1..99] +domain: finance +keywords: [unrealized-vat, vat-realization, payment-application, vat-entry, deferred-recognition] +technologies: [al] +countries: [w1] +application-area: [finance] +--- + +# Unrealized VAT + +## Description + +Unrealized VAT defers the VAT liability to the moment payment settles rather than the moment the invoice posts. When enabled on a VAT Posting Setup cell, posting the invoice creates a VAT Entry with a zero amount in the Amount column and the full amount in Unrealized Amount. When a payment applies to the invoice via codeunit 226 (`CustEntry-Apply Posted Entries`) or 227 (`VendEntry-Apply Posted Entries`), additional VAT Entries are created that move the amount from Unrealized to Realized in proportion to the payment applied. Partial payments realize partial VAT. + +This matters for three reasons. First, the VAT return runs on realized entries only, so the period the liability is declared depends on payment date, not invoice date. Second, the chain of VAT Entries grows: one per application event. Third, reversing an application (un-applying entries) creates mirror VAT Entries that reverse the realization — never edit existing entries. The mechanism is well-defined; bugs usually stem from assumptions that VAT always realizes at posting. + +## Best Practice + +When migrating a company onto Unrealized VAT, take the effective-date approach: new invoices carry the new setup, historical open invoices post-realize at payment under the old setup. Mixing both setups on the same open invoice produces a VAT Entry chain that does not balance. + +## Anti Pattern + +Expecting the VAT account to equal the invoice's VAT amount immediately after the invoice posts. Under Unrealized VAT, the account is zero until the first payment applies. Reports that compare invoice VAT to G/L VAT balance must filter by realization state or they report every unpaid invoice as an error. + +## Provenance + +Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "VAT Calculation") on 2026-04-21. diff --git a/microsoft/knowledge/finance/vat-on-prepayment-chains.md b/microsoft/knowledge/finance/vat-on-prepayment-chains.md new file mode 100644 index 0000000..5d60ddd --- /dev/null +++ b/microsoft/knowledge/finance/vat-on-prepayment-chains.md @@ -0,0 +1,28 @@ +--- +bc-version: [1..99] +domain: finance +keywords: [vat-prepayment, prepayment-chain, credit-memo, rounding, proportional-adjustment] +technologies: [al] +countries: [w1] +application-area: [finance] +--- + +# VAT on prepayment chains + +## Description + +When a Sales or Purchase document carries a prepayment percentage, Business Central splits VAT across the prepayment and the final invoice. The prepayment invoice posts VAT on the prepayment percentage of the order; the final invoice posts VAT on the remaining portion and contains a deduction line that reverses the prepayment's VAT share. If a credit memo reverses either leg, its VAT must proportion across whatever has already posted. This chain — prepayment invoice → final invoice → optional credit memo — must reconcile to the same VAT amount a one-shot invoice would have produced. + +The chain is rounding-sensitive: each leg rounds independently per the VAT posting setup, and the sum of rounded legs can differ from rounding the total once. In multi-currency chains, each leg may use a different exchange rate (posting date differs), further complicating reconciliation. Symptom: the VAT account carries a 0.01 or 0.02 residual after all legs post; no single posting caused it, but the chain does not balance to the expected single-invoice equivalent. + +## Best Practice + +Let Business Central compute and post the VAT on every leg rather than overriding it. The proportional-adjustment logic inside codeunit 80/90 expects to own these amounts; manual overrides produce residuals that only surface at VAT return time. + +## Anti Pattern + +Correcting a prepayment-chain mismatch by modifying the VAT Entry on the final invoice. The entry is linked to the G/L Entry and the sales invoice line; editing it desynchronises the three and the VAT return aggregates the wrong number. Post a corrective document instead. + +## Provenance + +Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "VAT Calculation" and "VAT Edge Cases That Cause Triage Issues") on 2026-04-21. diff --git a/microsoft/knowledge/finance/vat-posting-setup.md b/microsoft/knowledge/finance/vat-posting-setup.md new file mode 100644 index 0000000..6a430f6 --- /dev/null +++ b/microsoft/knowledge/finance/vat-posting-setup.md @@ -0,0 +1,28 @@ +--- +bc-version: [1..99] +domain: finance +keywords: [vat, vat-posting-setup, vat-business-group, vat-product-group, reverse-charge, full-vat] +technologies: [al] +countries: [w1] +application-area: [finance] +--- + +# VAT posting setup + +## Description + +VAT Posting Setup (table 325) is the matrix that tells Business Central how to compute VAT for every combination of VAT Business Posting Group (who the counterparty is — domestic, EU, export) and VAT Product Posting Group (what is being transacted — standard goods, reduced-rate goods, exempt services). Each cell of the matrix declares the VAT % and the VAT Calculation Type that applies when that combination appears on a posting. + +Three calculation types cover the common cases. Normal VAT applies the rate as a percentage of the line amount — the standard sales/purchase tax path. Reverse Charge VAT records the VAT on both sides of the transaction without a cash movement; the buyer, not the seller, is responsible for remitting it to the authority. Full VAT treats the entire line amount as VAT with no underlying taxable base — used for VAT-only correction documents. Every posted line in the document flows through the matching cell; misconfigured cells produce posting errors, incorrect returns, or off-balance VAT accounts. + +## Best Practice + +Populate the full matrix, including "not applicable" cells (with zero rate and a note). Missing cells produce an error message that names the combination the user tried to use, which is clearer than an unexpected zero-rate post that would mask the misconfiguration. + +## Anti Pattern + +Creating a single catch-all VAT Business Group for "everyone" and a single VAT Product Group for "everything." Reporting the VAT return later becomes impossible because every transaction collapses into one cell; the Authority requires transaction-level breakdown. + +## Provenance + +Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "VAT Calculation") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Finance\VAT\`. diff --git a/microsoft/skills/bc-domain-context.md b/microsoft/skills/bc-domain-context.md new file mode 100644 index 0000000..43414fb --- /dev/null +++ b/microsoft/skills/bc-domain-context.md @@ -0,0 +1,101 @@ +--- +kind: action-skill +id: bc-domain-context +version: 1 +title: BC domain context +description: Returns Business Central domain-knowledge references for a task's application area. +inputs: [file-path, repository] +outputs: [findings-report] +bc-version: [1..99] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# BC domain context + +Surfaces the Business Central domain-knowledge files that apply to a task's application area. This is a leaf action skill — it invokes no sub-skills and produces informational findings that cite the relevant knowledge files. Consumers that need to reason about a BC module (triage bots, code assistants, review helpers) invoke this skill, read the cited files, and bring that content into their own context. + +The skill produces a single JSON document conforming to the DO output contract. + +## Source + +Collect knowledge files under `*/knowledge//**/*.md` for every value in `task-context.application-area`, across every enabled layer (`/microsoft/`, `/community/`, `/custom/`). When `application-area` is absent, empty, or `[all]`, source from every area-named knowledge folder across the enabled layers — the full domain corpus. + +## Relevance + +Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context: + +- `bc-version` — match the task's BC version. If the orchestrator did not supply one, the dimension is `unknown`. +- `technologies` — `[al]`. Knowledge files that declare other technologies (e.g. `[powershell]`) must still intersect with `[al]`; discard those that do not. +- `countries` — `[w1]` matches any task context; country-specific files match only when the task-context `countries` overlaps the file's declared countries. +- `application-area` — the file's `application-area` must include every value the task supplied, OR be `[all]`. + +Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the `message` MUST name the dimensions that were unknown. + +## Worklist + +Narrow the relevant set to the files that will be cited: + +1. **Goal-directed narrowing.** When `task-context.goal` contains concrete domain tokens beyond the `bc-domain-context for ` prefix (for example, *"VAT on prepayment credit memo"*, *"flushing method scrap"*, *"warehouse directed pick"*), score each candidate's `keywords`, filename, and `## Description` content against those tokens. Keep the highest-scoring 15 files. Ties are broken by keyword-overlap count, then filename specificity. + +2. **Full-area fallback.** When the goal contains no tokens beyond the prefix (the consumer wants the whole area), skip scoring and keep every relevant file. + +3. **Layer precedence.** Resolve conflicts per READ: `/custom/` wins over `/microsoft/`, `/microsoft/` wins over `/community/`. For knowledge files sharing the same `/.md` path across layers, keep the highest-precedence file and record each suppressed file in `suppressed[]` with `reason: "layer-precedence"`. Files hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`. + +If the post-conflict worklist is empty because no area knowledge applies to the task, emit `outcome: "no-knowledge"`. If the relevance filter ruled out every file because of a mismatch (e.g. the task targets a BC version no file supports), emit `outcome: "not-applicable"`. + +## Action + +For each worklist file, emit one finding: + +- `id` — the file's repo-relative path (per DO, citation-based findings use the primary reference path as the id). +- `severity` — `info`. This skill never blocks; it is purely informational. +- `message` — the file's H1 title followed by the first two to three sentences of its `## Description` section. Strip leading whitespace and heading markers. When any frontmatter dimension was `unknown` during Relevance, append `" (conditional on: )"` to the message. +- `location` — omitted. Findings from this skill are not tied to a source-code location. +- `references` — a single reference object: `{ "path": "", "sha": "" }`. Include `sha` when the consumer invoked the skill against a specific BCQuality commit. +- `confidence` — `high` when every frontmatter dimension matched exactly; `medium` when any dimension was `unknown`. + +Populate `summary.counts` with every emitted finding counted as `info`. Populate `summary.coverage` with `worklist-size` and `items-evaluated` — both equal the number of worklist files when the skill finishes normally. + +## Output + +Conforms to the DO output contract. A populated example for a finance-area task: + +```json +{ + "skill": { "id": "bc-domain-context", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 13 }, + "coverage": { "worklist-size": 13, "items-evaluated": 13 } + }, + "findings": [ + { + "id": "microsoft/knowledge/finance/vat-on-prepayment-chains.md", + "severity": "info", + "message": "VAT on prepayment chains. The VAT amount on a prepayment invoice is computed on the prepayment percentage, then adjusted when the final invoice posts and again when a credit memo reverses either leg. Each step must reconcile against the sales-header prepayment account to avoid rounding drift.", + "references": [ + { "path": "microsoft/knowledge/finance/vat-on-prepayment-chains.md" } + ], + "confidence": "high" + } + ], + "suppressed": [] +} +``` + +The empty-corpus case — the state before any area knowledge lands in BCQuality — produces: + +```json +{ + "skill": { "id": "bc-domain-context", "version": 1 }, + "outcome": "no-knowledge", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 0, "items-evaluated": 0 } + }, + "findings": [], + "suppressed": [] +} +``` From 9dad34f48ac15460164c0076f447b2bc18e3db9d Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Wed, 22 Apr 2026 14:03:09 +0200 Subject: [PATCH 03/15] Revert "Add unit tests and knowledge files for BC domain context" This reverts commit 7fbb121c249cc92283e4bc05cd8a6a99ea33a4ee. --- .github/scripts/bc_domain_context.py | 373 ------------------ .../scripts/tests/test_bc_domain_context.py | 297 -------------- README.md | 4 +- .../knowledge/finance/chart-of-accounts.md | 28 -- .../finance/codeunit-12-gen-jnl-post-line.md | 28 -- .../codeunit-408-dimension-management.md | 28 -- .../finance/dimension-combinations.md | 28 -- .../finance/dimension-default-priority.md | 28 -- microsoft/knowledge/finance/dimensions.md | 30 -- .../knowledge/finance/entry-application.md | 30 -- .../finance/exchange-rate-adjustment.md | 28 -- .../finance/general-journal-posting.md | 28 -- .../finance/general-ledger-entries.md | 28 -- .../finance/multi-currency-rounding.md | 28 -- microsoft/knowledge/finance/unrealized-vat.md | 28 -- .../finance/vat-on-prepayment-chains.md | 28 -- .../knowledge/finance/vat-posting-setup.md | 28 -- microsoft/skills/bc-domain-context.md | 101 ----- 18 files changed, 1 insertion(+), 1170 deletions(-) delete mode 100644 .github/scripts/bc_domain_context.py delete mode 100644 .github/scripts/tests/test_bc_domain_context.py delete mode 100644 microsoft/knowledge/finance/chart-of-accounts.md delete mode 100644 microsoft/knowledge/finance/codeunit-12-gen-jnl-post-line.md delete mode 100644 microsoft/knowledge/finance/codeunit-408-dimension-management.md delete mode 100644 microsoft/knowledge/finance/dimension-combinations.md delete mode 100644 microsoft/knowledge/finance/dimension-default-priority.md delete mode 100644 microsoft/knowledge/finance/dimensions.md delete mode 100644 microsoft/knowledge/finance/entry-application.md delete mode 100644 microsoft/knowledge/finance/exchange-rate-adjustment.md delete mode 100644 microsoft/knowledge/finance/general-journal-posting.md delete mode 100644 microsoft/knowledge/finance/general-ledger-entries.md delete mode 100644 microsoft/knowledge/finance/multi-currency-rounding.md delete mode 100644 microsoft/knowledge/finance/unrealized-vat.md delete mode 100644 microsoft/knowledge/finance/vat-on-prepayment-chains.md delete mode 100644 microsoft/knowledge/finance/vat-posting-setup.md delete mode 100644 microsoft/skills/bc-domain-context.md diff --git a/.github/scripts/bc_domain_context.py b/.github/scripts/bc_domain_context.py deleted file mode 100644 index 6bb6661..0000000 --- a/.github/scripts/bc_domain_context.py +++ /dev/null @@ -1,373 +0,0 @@ -#!/usr/bin/env python3 -""" -Reference implementation of the bc-domain-context action skill. - -Executes the skill's Source -> Relevance -> Worklist -> Action pipeline against -a BCQuality repository root and returns a findings-report dict conforming to -the DO output contract. - -This module is the *spec by example* for consumers that reimplement the skill -in other languages (for example, the Triage agent's Node.js client). Tests in -.github/scripts/tests/test_bc_domain_context.py exercise this implementation -against fixture knowledge trees. - -Usage (from Python): - from bc_domain_context import run_bc_domain_context - report = run_bc_domain_context(repo_root, task_context) - -Skill behaviour is defined in microsoft/skills/bc-domain-context.md; READ's -frontmatter-matching semantics live in skills/read.md. -""" -from __future__ import annotations - -import re -import sys -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Iterable - -try: - import yaml -except ImportError: - sys.stderr.write("ERROR: PyYAML is required. Install with: pip install pyyaml\n") - sys.exit(2) - - -LAYERS = ("custom", "microsoft", "community") # highest precedence first -SKILL_ID = "bc-domain-context" -SKILL_VERSION = 1 - - -@dataclass -class KnowledgeFile: - path: str # repo-relative, forward slashes - layer: str # "microsoft" | "community" | "custom" - domain: str # folder name - slug: str # filename stem - frontmatter: dict[str, Any] - title: str # first H1 in body, or slug if absent - description: str # prose after "## Description", trimmed - - -# --- Frontmatter + body parsing --------------------------------------------- - -def _parse_markdown(text: str) -> tuple[dict[str, Any] | None, str]: - lines = text.splitlines() - if not lines or lines[0].rstrip() != "---": - return None, text - for i in range(1, len(lines)): - if lines[i].rstrip() == "---": - try: - fm = yaml.safe_load("\n".join(lines[1:i])) or {} - except yaml.YAMLError: - return None, text - if not isinstance(fm, dict): - return None, text - return fm, "\n".join(lines[i + 1:]) - return None, text - - -def _extract_title(body: str, fallback: str) -> str: - for line in body.splitlines(): - m = re.match(r"^#\s+(.+?)\s*$", line) - if m: - return m.group(1).strip() - return fallback - - -def _extract_description(body: str) -> str: - lines = body.splitlines() - for i, line in enumerate(lines): - if re.match(r"^##\s+Description\s*$", line): - buf: list[str] = [] - for j in range(i + 1, len(lines)): - if re.match(r"^##\s+", lines[j]): - break - buf.append(lines[j]) - return "\n".join(buf).strip() - return "" - - -# --- bc-version expansion (matches validate_frontmatter.expand_bc_version) -- - -_RANGE = re.compile(r"^(\d+)\.\.(\d+)$") - - -def _expand_bc_version(value: Any) -> list[int] | None: - if not isinstance(value, list) or not value: - return None - if all(isinstance(v, int) and not isinstance(v, bool) and v > 0 for v in value): - return sorted(set(value)) - if len(value) == 1 and isinstance(value[0], str): - m = _RANGE.match(value[0].strip()) - if m: - start, end = int(m.group(1)), int(m.group(2)) - if start <= end: - return list(range(start, end + 1)) - return None - - -# --- READ's frontmatter matching rules -------------------------------------- - -def _matches( - kf: KnowledgeFile, task_context: dict[str, Any] -) -> tuple[bool, list[str]]: - """Return (applicable, unknown_dimensions) per READ's semantics. - - A file is applicable when every rule matches. A rule is "unknown" when the - task context omits a dimension and the file does not declare a universal - sentinel for it; unknown rules do not disqualify the file but force a - medium-confidence ceiling on derived findings. - """ - unknown: list[str] = [] - fm = kf.frontmatter - - # bc-version - file_versions = _expand_bc_version(fm.get("bc-version")) - if file_versions is None: - return False, unknown - task_version = task_context.get("bc-version") - if task_version is None: - unknown.append("bc-version") - elif task_version not in file_versions: - return False, unknown - - # technologies (no sentinel) - file_techs = set(fm.get("technologies") or []) - task_techs = set(task_context.get("technologies") or []) - if not task_techs: - unknown.append("technologies") - elif not (file_techs & task_techs): - return False, unknown - - # countries (sentinel: w1) - file_countries = set(fm.get("countries") or []) - task_countries = set(task_context.get("countries") or []) - if "w1" in file_countries: - pass - elif not task_countries: - unknown.append("countries") - elif not (file_countries & task_countries): - return False, unknown - - # application-area (sentinel: all) - file_areas = set(fm.get("application-area") or []) - task_areas = set(task_context.get("application-area") or []) - if "all" in file_areas: - pass - elif not task_areas or "all" in task_areas: - # Empty or [all] in task means "any area"; file-level specific area - # still matches against any-area when sourced from the area folder. - pass - elif not (file_areas & task_areas): - return False, unknown - - return True, unknown - - -# --- Knowledge corpus load -------------------------------------------------- - -def _load_knowledge_file(root: Path, path: Path) -> KnowledgeFile | None: - rel = path.relative_to(root).as_posix() - parts = rel.split("/") - if len(parts) != 4 or parts[1] != "knowledge": - return None - layer, _, domain, filename = parts - slug = Path(filename).stem - try: - text = path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - return None - fm, body = _parse_markdown(text) - if fm is None: - return None - return KnowledgeFile( - path=rel, - layer=layer, - domain=domain, - slug=slug, - frontmatter=fm, - title=_extract_title(body, slug), - description=_extract_description(body), - ) - - -def _load_corpus( - root: Path, enabled_layers: Iterable[str], areas: Iterable[str] | None -) -> list[KnowledgeFile]: - """Walk /knowledge//*.md for enabled layers. - - If `areas` is None or contains "all", walk every area folder. Otherwise, - walk only the named area folders. - """ - enabled = set(enabled_layers) & set(LAYERS) - area_set = None - if areas is not None: - areas_list = list(areas) - if areas_list and "all" not in areas_list: - area_set = set(areas_list) - corpus: list[KnowledgeFile] = [] - for layer in enabled: - knowledge_root = root / layer / "knowledge" - if not knowledge_root.is_dir(): - continue - for domain_dir in knowledge_root.iterdir(): - if not domain_dir.is_dir(): - continue - if area_set is not None and domain_dir.name not in area_set: - continue - for md_path in domain_dir.glob("*.md"): - kf = _load_knowledge_file(root, md_path) - if kf is not None: - corpus.append(kf) - return corpus - - -# --- Layer precedence ------------------------------------------------------- - -def _resolve_precedence( - files: list[KnowledgeFile], -) -> tuple[list[KnowledgeFile], list[dict[str, Any]]]: - """Keep the highest-precedence file per (domain, slug). Return (kept, suppressed). - - suppressed entries have shape { "reference": { "path": ... }, "reason": "layer-precedence" }. - """ - precedence = {layer: i for i, layer in enumerate(LAYERS)} # lower index = higher precedence - groups: dict[tuple[str, str], list[KnowledgeFile]] = {} - for f in files: - groups.setdefault((f.domain, f.slug), []).append(f) - kept: list[KnowledgeFile] = [] - suppressed: list[dict[str, Any]] = [] - for group in groups.values(): - group.sort(key=lambda f: precedence.get(f.layer, 99)) - kept.append(group[0]) - for loser in group[1:]: - suppressed.append({ - "reference": {"path": loser.path}, - "reason": "layer-precedence", - }) - return kept, suppressed - - -# --- Worklist narrowing ----------------------------------------------------- - -_GOAL_PREFIX = re.compile(r"^bc-domain-context(?:\s+for\s+[a-z0-9,\- ]+)?\s*", re.IGNORECASE) -_TOKEN = re.compile(r"[a-z0-9]+") - - -def _extract_goal_tokens(goal: str) -> list[str]: - """Strip the 'bc-domain-context for ' prefix and return significant tokens.""" - if not goal: - return [] - stripped = _GOAL_PREFIX.sub("", goal).strip() - if not stripped: - return [] - return [t for t in _TOKEN.findall(stripped.lower()) if len(t) >= 3] - - -def _score(kf: KnowledgeFile, tokens: list[str]) -> int: - if not tokens: - return 0 - kw = {k.lower() for k in (kf.frontmatter.get("keywords") or [])} - text = f"{kf.slug} {kf.title} {kf.description}".lower() - score = 0 - for t in tokens: - if t in kw: - score += 3 - if t in text: - score += 1 - return score - - -def _narrow( - kept: list[KnowledgeFile], task_context: dict[str, Any], max_top: int = 15 -) -> list[KnowledgeFile]: - tokens = _extract_goal_tokens(task_context.get("goal") or "") - if not tokens: - return kept - scored = [(kf, _score(kf, tokens)) for kf in kept] - scored.sort(key=lambda p: (-p[1], p[0].path)) - # Keep only files with positive score; fall back to full set if none score. - positive = [kf for kf, s in scored if s > 0] - if not positive: - return kept - return positive[:max_top] - - -# --- Message construction --------------------------------------------------- - -def _message(kf: KnowledgeFile, unknown: list[str]) -> str: - first_sentences = re.split(r"(?<=[.!?])\s+", kf.description, maxsplit=3) - lead = " ".join(first_sentences[:3]).strip() if first_sentences else "" - msg = f"{kf.title}. {lead}" if lead else kf.title - if unknown: - msg += f" (conditional on: {', '.join(sorted(unknown))})" - return msg - - -# --- Main entrypoint -------------------------------------------------------- - -def run_bc_domain_context( - root: Path | str, task_context: dict[str, Any] -) -> dict[str, Any]: - """Execute the skill against the repository at `root`. - - Returns a findings-report dict conforming to the DO output contract. - """ - root = Path(root) - enabled = task_context.get("enabled-layers") or list(LAYERS) - areas = task_context.get("application-area") - - corpus = _load_corpus(root, enabled, areas) - if not corpus: - return _report("no-knowledge", [], []) - - applicable: list[tuple[KnowledgeFile, list[str]]] = [] - for kf in corpus: - ok, unknown = _matches(kf, task_context) - if ok: - applicable.append((kf, unknown)) - if not applicable: - return _report("not-applicable", [], []) - - kept, suppressed = _resolve_precedence([kf for kf, _ in applicable]) - kept_set = {kf.path for kf in kept} - unknown_by_path = {kf.path: u for kf, u in applicable if kf.path in kept_set} - - worklist = _narrow(kept, task_context) - - findings: list[dict[str, Any]] = [] - for kf in worklist: - unknown = unknown_by_path.get(kf.path, []) - findings.append({ - "id": kf.path, - "severity": "info", - "message": _message(kf, unknown), - "references": [{"path": kf.path}], - "confidence": "medium" if unknown else "high", - }) - - return _report("completed", findings, suppressed) - - -def _report( - outcome: str, findings: list[dict[str, Any]], suppressed: list[dict[str, Any]] -) -> dict[str, Any]: - return { - "skill": {"id": SKILL_ID, "version": SKILL_VERSION}, - "outcome": outcome, - "summary": { - "counts": { - "blocker": 0, - "major": 0, - "minor": 0, - "info": len(findings), - }, - "coverage": { - "worklist-size": len(findings), - "items-evaluated": len(findings), - }, - }, - "findings": findings, - "suppressed": suppressed, - } diff --git a/.github/scripts/tests/test_bc_domain_context.py b/.github/scripts/tests/test_bc_domain_context.py deleted file mode 100644 index b4a072e..0000000 --- a/.github/scripts/tests/test_bc_domain_context.py +++ /dev/null @@ -1,297 +0,0 @@ -#!/usr/bin/env python3 -""" -Tests for the bc-domain-context reference implementation. - -Run with: python -m unittest .github.scripts.tests.test_bc_domain_context -Or: cd .github/scripts/tests && python -m unittest test_bc_domain_context - -The tests build fixture knowledge trees in tempfile directories and exercise -the skill's Source / Relevance / Worklist / Action pipeline end to end. -""" -from __future__ import annotations - -import os -import sys -import tempfile -import textwrap -import unittest -from pathlib import Path - -# Add the scripts directory (parent of tests/) to sys.path so we can import -# bc_domain_context.py. -_SCRIPTS_DIR = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(_SCRIPTS_DIR)) - -from bc_domain_context import run_bc_domain_context # noqa: E402 - - -def _write( - root: Path, - layer: str, - domain: str, - slug: str, - *, - bc_version: str = "[26..28]", - technologies: str = "[al]", - countries: str = "[w1]", - application_area: str | None = None, - keywords: str = "[sample]", - domain_value: str | None = None, - description: str = "Short description. Another sentence.", - title: str | None = None, -) -> Path: - """Write a fixture knowledge file and return its path.""" - if application_area is None: - application_area = f"[{domain}]" - if domain_value is None: - domain_value = domain - if title is None: - title = slug.replace("-", " ").capitalize() - target = root / layer / "knowledge" / domain / f"{slug}.md" - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text( - textwrap.dedent( - f"""\ - --- - bc-version: {bc_version} - domain: {domain_value} - keywords: {keywords} - technologies: {technologies} - countries: {countries} - application-area: {application_area} - --- - - # {title} - - ## Description - - {description} - """ - ), - encoding="utf-8", - ) - return target - - -class BcDomainContextTests(unittest.TestCase): - def setUp(self): - self._tmp = tempfile.TemporaryDirectory() - self.root = Path(self._tmp.name) - self.addCleanup(self._tmp.cleanup) - - # --- Core filtering --- - - def test_filter_by_area_returns_only_requested_area(self): - _write(self.root, "microsoft", "finance", "chart-of-accounts") - _write(self.root, "microsoft", "finance", "general-ledger-entries") - _write(self.root, "microsoft", "finance", "dimensions") - _write(self.root, "microsoft", "sales", "order-to-cash") - _write(self.root, "microsoft", "sales", "pricing") - - report = run_bc_domain_context(self.root, { - "application-area": ["finance"], - "technologies": ["al"], - "bc-version": 28, - "countries": ["w1"], - }) - - self.assertEqual(report["outcome"], "completed") - paths = sorted(f["id"] for f in report["findings"]) - self.assertEqual(paths, [ - "microsoft/knowledge/finance/chart-of-accounts.md", - "microsoft/knowledge/finance/dimensions.md", - "microsoft/knowledge/finance/general-ledger-entries.md", - ]) - for finding in report["findings"]: - self.assertEqual(finding["severity"], "info") - self.assertEqual(finding["confidence"], "high") - - def test_application_area_all_returns_union(self): - _write(self.root, "microsoft", "finance", "chart-of-accounts") - _write(self.root, "microsoft", "sales", "order-to-cash") - _write(self.root, "microsoft", "manufacturing", "bom") - - report = run_bc_domain_context(self.root, { - "application-area": ["all"], - "technologies": ["al"], - "bc-version": 28, - "countries": ["w1"], - }) - - self.assertEqual(report["outcome"], "completed") - self.assertEqual(len(report["findings"]), 3) - paths = {f["id"] for f in report["findings"]} - self.assertIn("microsoft/knowledge/finance/chart-of-accounts.md", paths) - self.assertIn("microsoft/knowledge/sales/order-to-cash.md", paths) - self.assertIn("microsoft/knowledge/manufacturing/bom.md", paths) - - def test_technologies_mismatch_drops_file(self): - _write(self.root, "microsoft", "finance", "al-concept") - _write( - self.root, - "microsoft", - "finance", - "kql-only-concept", - technologies="[kql]", - ) - - report = run_bc_domain_context(self.root, { - "application-area": ["finance"], - "technologies": ["al"], - "bc-version": 28, - "countries": ["w1"], - }) - - paths = {f["id"] for f in report["findings"]} - self.assertIn("microsoft/knowledge/finance/al-concept.md", paths) - self.assertNotIn("microsoft/knowledge/finance/kql-only-concept.md", paths) - - def test_no_matching_knowledge_returns_not_applicable(self): - _write(self.root, "microsoft", "finance", "chart-of-accounts") - - report = run_bc_domain_context(self.root, { - "application-area": ["manufacturing"], - "technologies": ["al"], - "bc-version": 28, - "countries": ["w1"], - }) - - self.assertEqual(report["outcome"], "no-knowledge") - self.assertEqual(report["findings"], []) - - # --- Layer precedence --- - - def test_layer_precedence_microsoft_wins_over_community(self): - _write(self.root, "community", "finance", "vat-on-prepayment") - _write(self.root, "microsoft", "finance", "vat-on-prepayment") - - report = run_bc_domain_context(self.root, { - "application-area": ["finance"], - "technologies": ["al"], - "bc-version": 28, - "countries": ["w1"], - }) - - paths = [f["id"] for f in report["findings"]] - self.assertEqual( - paths, ["microsoft/knowledge/finance/vat-on-prepayment.md"] - ) - suppressed_paths = [s["reference"]["path"] for s in report["suppressed"]] - self.assertEqual( - suppressed_paths, - ["community/knowledge/finance/vat-on-prepayment.md"], - ) - self.assertEqual(report["suppressed"][0]["reason"], "layer-precedence") - - def test_layer_precedence_custom_wins_over_microsoft(self): - _write(self.root, "microsoft", "finance", "vat-on-prepayment") - _write(self.root, "custom", "finance", "vat-on-prepayment") - - report = run_bc_domain_context(self.root, { - "application-area": ["finance"], - "technologies": ["al"], - "bc-version": 28, - "countries": ["w1"], - }) - - paths = [f["id"] for f in report["findings"]] - self.assertEqual( - paths, ["custom/knowledge/finance/vat-on-prepayment.md"] - ) - - # --- Conditional applicability (unknown dimensions) --- - - def test_unknown_bc_version_caps_confidence_at_medium(self): - _write(self.root, "microsoft", "finance", "chart-of-accounts") - _write(self.root, "microsoft", "finance", "dimensions") - - # bc-version omitted from task-context. - report = run_bc_domain_context(self.root, { - "application-area": ["finance"], - "technologies": ["al"], - "countries": ["w1"], - }) - - self.assertTrue(report["findings"]) - for finding in report["findings"]: - self.assertEqual(finding["confidence"], "medium") - self.assertIn("bc-version", finding["message"]) - - # --- Goal-directed narrowing --- - - def test_goal_tokens_narrow_worklist(self): - _write( - self.root, - "microsoft", - "finance", - "vat-on-prepayment-chains", - keywords="[vat, prepayment, credit-memo]", - ) - _write( - self.root, - "microsoft", - "finance", - "dimensions", - keywords="[dimensions, default-priority]", - ) - _write( - self.root, - "microsoft", - "finance", - "chart-of-accounts", - keywords="[chart, account]", - ) - - report = run_bc_domain_context(self.root, { - "goal": "bc-domain-context for finance — VAT wrong on prepayment credit memo", - "application-area": ["finance"], - "technologies": ["al"], - "bc-version": 28, - "countries": ["w1"], - }) - - paths = [f["id"] for f in report["findings"]] - self.assertIn( - "microsoft/knowledge/finance/vat-on-prepayment-chains.md", - paths, - ) - # The highest-scoring file should come first. - self.assertEqual( - paths[0], - "microsoft/knowledge/finance/vat-on-prepayment-chains.md", - ) - - def test_generic_goal_keeps_full_area(self): - _write(self.root, "microsoft", "finance", "chart-of-accounts") - _write(self.root, "microsoft", "finance", "dimensions") - - report = run_bc_domain_context(self.root, { - "goal": "bc-domain-context for finance", - "application-area": ["finance"], - "technologies": ["al"], - "bc-version": 28, - "countries": ["w1"], - }) - - self.assertEqual(len(report["findings"]), 2) - - # --- Layer disabling --- - - def test_disabled_layer_is_invisible(self): - _write(self.root, "community", "finance", "only-in-community") - _write(self.root, "microsoft", "finance", "only-in-microsoft") - - report = run_bc_domain_context(self.root, { - "application-area": ["finance"], - "technologies": ["al"], - "bc-version": 28, - "countries": ["w1"], - "enabled-layers": ["microsoft"], - }) - - paths = [f["id"] for f in report["findings"]] - self.assertEqual(paths, ["microsoft/knowledge/finance/only-in-microsoft.md"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/README.md b/README.md index f9c86cc..ed222d7 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Every knowledge file is a markdown file with mandatory YAML frontmatter. Files t ```yaml --- -bc-version: [26..28] # BC versions this applies to; use [1..99] for universal content +bc-version: [26..28] # BC versions this applies to domain: performance # security | performance | ux | telemetry | ... keywords: [query, filtering, partial] # free-text tags for retrieval technologies: [al] # al | javascript | powershell | ... @@ -63,8 +63,6 @@ application-area: [all] # finance | manufacturing | jobs | [all] All six fields are required. The schema is locked — changes require a PR approved by both maintainers. -**`bc-version` convention.** Content that describes version-specific behaviour (a query optimizer change in BC 26, a security API renamed in BC 27) lists the specific major versions or a tight range. Content that describes canonical BC concepts which are stable across every supported release — domain knowledge like `Codeunit 12 (Gen. Jnl.-Post Line)`, `T15 G/L Account`, VAT posting setup, the dimensions model — uses `bc-version: [1..99]`. This signals "applies to every BC version past, present, and future" without forcing an annual bump of every file when a new release ships. - ### Sections Every knowledge file must contain a `## Description` section. The following sections are optional but recommended: diff --git a/microsoft/knowledge/finance/chart-of-accounts.md b/microsoft/knowledge/finance/chart-of-accounts.md deleted file mode 100644 index 1255838..0000000 --- a/microsoft/knowledge/finance/chart-of-accounts.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [1..99] -domain: finance -keywords: [chart-of-accounts, gl-account, account-category, account-subcategory, financial-reports] -technologies: [al] -countries: [w1] -application-area: [finance] ---- - -# Chart of Accounts - -## Description - -The Chart of Accounts (table 15 — `G/L Account`) is the backbone of Business Central's financial reporting. Every posting in the system, no matter where it originates, eventually produces G/L Entries against accounts defined here. The chart's structure determines what financial statements look like: accounts carry an Account Category (Assets, Liabilities, Equity, Income, Cost of Goods Sold, Expense) and an Account Subcategory that groups them for statement rows. Financial reports (balance sheet, income statement, trial balance) aggregate entries by these classifications rather than by the raw account numbers. - -Because every sub-ledger (customer, vendor, item, fixed asset, bank) ultimately posts to G/L, the chart is the single integration point for all monetary movement. A miscategorised account shifts amounts between sections of the financial statements without producing a posting error — the numbers look fine at the account level and wrong at the statement level. - -## Best Practice - -Set Account Category and Account Subcategory on every G/L Account — do not leave them blank on new accounts. Run the financial report rebuild after restructuring the chart so the subcategory totals re-calculate against all historical entries. - -## Anti Pattern - -Using free-text Account Name as the only grouping signal. Reports that aggregate by name are brittle to typos and translation; category/subcategory are the authoritative grouping. - -## Provenance - -Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Chart of Accounts & G/L Posting") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Finance\`. diff --git a/microsoft/knowledge/finance/codeunit-12-gen-jnl-post-line.md b/microsoft/knowledge/finance/codeunit-12-gen-jnl-post-line.md deleted file mode 100644 index 55f3e02..0000000 --- a/microsoft/knowledge/finance/codeunit-12-gen-jnl-post-line.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [1..99] -domain: finance -keywords: [codeunit-12, gen-jnl-post-line, journal-posting, high-risk, ledger-integrity] -technologies: [al] -countries: [w1] -application-area: [finance] ---- - -# Codeunit 12 (Gen. Jnl.-Post Line) - -## Description - -Codeunit 12 is the single posting engine for every journal line in Business Central. General journals, payment journals, cash receipt journals, recurring journals, IC journals, and the journal-like intermediaries used by document posting (Sales-Post, Purch.-Post, Invoice Post. Buffer) all funnel through this codeunit to produce G/L Entries, Customer Ledger Entries, Vendor Ledger Entries, Bank Account Ledger Entries, VAT Entries, and Detailed Ledger Entries. The entry numbering, dimension resolution, multi-currency math, and VAT computation for every posted line happen here. - -Because every monetary posting passes through codeunit 12, any modification to its behaviour — even a seemingly local change to one sub-procedure — has repository-wide blast radius. A change intended to affect only Purchase posting will also hit Sales, General Journal, Intercompany, bank payments, and every extension that raises integration events on codeunit 12's publishers. Debugging an unexpected posting change across multiple modules frequently traces back here. - -## Best Practice - -Extend codeunit 12 only through the published integration events (`OnAfterPostGLAcc`, `OnAfterPostCustVendAccount`, etc.). Subscribing is additive and local to the subscriber; forking codeunit 12's body inside an extension loses access to Microsoft's future fixes. - -## Anti Pattern - -Modifying codeunit 12's core computation in a customisation to fix a reported issue. Every subsequent BC platform upgrade must merge around the modification, and the modification's side effects on other modules are rarely exhaustively tested. Use events. - -## Provenance - -Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "High-Risk Areas") on 2026-04-21. diff --git a/microsoft/knowledge/finance/codeunit-408-dimension-management.md b/microsoft/knowledge/finance/codeunit-408-dimension-management.md deleted file mode 100644 index 31cad48..0000000 --- a/microsoft/knowledge/finance/codeunit-408-dimension-management.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [1..99] -domain: finance -keywords: [codeunit-408, dimension-management, dimension-merge, high-risk, dimension-set] -technologies: [al] -countries: [w1] -application-area: [finance] ---- - -# Codeunit 408 (Dimension Management) - -## Description - -Codeunit 408 is the central broker for every dimension operation in Business Central. It resolves Default Dimensions into concrete Dimension Set Entries, deduplicates sets by Dimension Set ID, merges header/line/master-data defaults during posting, enforces Dimension Combination rules, and owns the API that every other module uses to read or write dimensions. Sales, Purchase, Manufacturing, Warehouse, Fixed Assets, and Job posting all call into this codeunit; the codeunit is also how BC-internal UI controls retrieve the dimension values shown on a document. - -A modification here propagates to every posted dimension, across every module. The blast radius is not bounded by "we only customised Sales" — a change to the merge logic that Sales happens to exercise may shift dimensions on a Job Journal that shares no code with Sales. Subscribers to codeunit 408's integration events are safe; direct modifications are a high-risk change that frequently produces silent drift (dimension values on ledger entries that are defensible line by line but produce wrong totals in the financial statements). - -## Best Practice - -Extend only via the published integration events or by subscribing to business-layer events that codeunit 408 emits during its lifecycle. Review every dimension-related extension as part of every BC upgrade to confirm its event subscribers still fire. - -## Anti Pattern - -Writing to Dimension Set Entries (table 480) from custom code to "fix" a miscategorised entry. The set is shared across many entries; editing the set retroactively reclassifies every entry that referenced it, usually in ways the author did not intend. - -## Provenance - -Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "High-Risk Areas") on 2026-04-21. diff --git a/microsoft/knowledge/finance/dimension-combinations.md b/microsoft/knowledge/finance/dimension-combinations.md deleted file mode 100644 index 008911e..0000000 --- a/microsoft/knowledge/finance/dimension-combinations.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [1..99] -domain: finance -keywords: [dimension-combination, blocked-combination, dimension-matrix, dimension-value-combination] -technologies: [al] -countries: [w1] -application-area: [finance] ---- - -# Dimension combinations - -## Description - -Dimension Combinations (tables 350 — `Dimension Combination`, and 351 — `Dimension Value Combination`) are the guardrail that restricts which dimension values may coexist on the same posting. Table 350 records pair-level rules for two dimensions (typically Global Dimension 1 and Global Dimension 2): the pair may be Blocked, Limited (only specific value pairs allowed), or blank (free). Table 351 records the allowed value pairs under a Limited combination. The check runs during posting via codeunit 408; when a rejected pair arrives on a Dimension Set, posting fails with a specific error naming the blocked combination. - -Combinations are the right mechanism for organisational rules like "the Marketing department cannot post to the Factory location" — they enforce once, at post time, across every document type. They are the wrong mechanism for user-input validation (they do not fire until post), and they are often surprising to users who see them for the first time years into a deployment because they were set up once and forgotten. - -## Best Practice - -When introducing a new blocked combination, run a what-if query against open documents and journal batches first. Existing lines whose dimensions already violate the new rule will fail posting as soon as the rule activates; fix those lines before turning it on. - -## Anti Pattern - -Using Dimension Combinations to simulate permission checks. They gate posting, not data entry, and they fire in every module — a combination added to enforce a Sales workflow may suddenly block a General Journal entry no one expected. - -## Provenance - -Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Dimensions — The #1 Source of Finance Issues") on 2026-04-21. diff --git a/microsoft/knowledge/finance/dimension-default-priority.md b/microsoft/knowledge/finance/dimension-default-priority.md deleted file mode 100644 index 79df955..0000000 --- a/microsoft/knowledge/finance/dimension-default-priority.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [1..99] -domain: finance -keywords: [dimension-default, dimension-priority, posting-conflict, dimension-merge, troubleshooting] -technologies: [al] -countries: [w1] -application-area: [finance] ---- - -# Dimension default priority - -## Description - -At post time, Business Central merges dimension values from several sources into the final Dimension Set for each ledger entry. The merge honours a priority order: document header defaults (copied from customer/vendor at document creation) are the base, document line defaults overlay header (customer/vendor/item/G/L account defaults applied line by line), and line-level user edits overlay the defaults. For Gen. Journal posting, G/L Account default dimensions are applied inside codeunit 12 after the line is otherwise finalised. Dimension Combinations (tables 350/351) are a final gate that may reject the merged set outright. - -The single most common posting error in finance is a conflict during this merge: a line-default mandatory dimension conflicts with a header-default Same Code rule, or a Dimension Combination rejects a pair that neither source knew about. Users see the error only at post time, often long after the values were set. Troubleshooting requires tracing back through each source layer. - -## Best Practice - -When a post fails on dimensions, inspect in this order: (1) the error message's named conflict, (2) Default Dimensions on every master referenced by the document (customer, vendor, items, G/L accounts), (3) the Dimension Combination matrix, (4) document-header dimensions for staleness (a header-level change does NOT cascade to existing lines; if the header was re-coded after lines were entered, line defaults no longer match). - -## Anti Pattern - -Manually forcing a Dimension Set ID on a ledger entry to bypass the merge. The entry then carries a set that does not match its source defaults, and next-period reports silently disagree with the journal audit trail. - -## Provenance - -Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (sections: "Dimensions — The #1 Source of Finance Issues", "Dimension Conflict Troubleshooting") on 2026-04-21. diff --git a/microsoft/knowledge/finance/dimensions.md b/microsoft/knowledge/finance/dimensions.md deleted file mode 100644 index 362f4d3..0000000 --- a/microsoft/knowledge/finance/dimensions.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -bc-version: [1..99] -domain: finance -keywords: [dimensions, default-dimension, dimension-set, codeunit-408, global-dimension] -technologies: [al] -countries: [w1] -application-area: [finance] ---- - -# Dimensions - -## Description - -Dimensions are analytical tags — Department, Project, Region, etc. — attached to every posting so financial reports can filter and group by business attributes without adding columns to every ledger table. Two representations coexist: Default Dimensions (table 352) declare per-record defaults on masters (customer, vendor, item, G/L account, employee), and Dimension Set Entries (table 480) record the actual combinations carried on each ledger entry. A Dimension Set ID on a ledger entry references the exact set of dimension values; multiple entries sharing a set reuse the same ID rather than duplicating rows. - -Codeunit 408 (`Dimension Management`) is the central broker: it resolves defaults into concrete sets at posting time, deduplicates sets, and enforces dimension combination rules. It also owns the merge logic that walks document header, document line, and master-data defaults to produce the final set. - -Default Dimensions carry one of four rules per dimension: Code Mandatory (posting blocks without a value), Same Code (the value must match the master's default exactly), No Code (posting blocks if any value is provided), or blank (free choice, no constraint). The rule is enforced at post time, not at entry; user-interface entry may allow setting values that later fail posting. - -## Best Practice - -Set Global Dimension 1/2 on every master that drives dimension analysis; this prepopulates document lines without users remembering to add them. - -## Anti Pattern - -Introducing a new required dimension mid-year without backfilling existing open documents. Posting will fail for every document whose header was created before the rule existed. - -## Provenance - -Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Dimensions — The #1 Source of Finance Issues") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Foundation\Dimensions\`. diff --git a/microsoft/knowledge/finance/entry-application.md b/microsoft/knowledge/finance/entry-application.md deleted file mode 100644 index 90bf0a4..0000000 --- a/microsoft/knowledge/finance/entry-application.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -bc-version: [1..99] -domain: finance -keywords: [entry-application, remaining-amount, payment-discount, payment-tolerance, codeunit-226, codeunit-227] -technologies: [al] -countries: [w1] -application-area: [finance] ---- - -# Entry application - -## Description - -Entry application is the mechanism by which payments close invoices, credit memos offset invoices, and refunds close credits. A customer ledger entry carries a Remaining Amount that tracks the unapplied balance; an application event (Apply Customer Entries / Apply Vendor Entries) reduces Remaining Amount on both sides of the application until one side hits zero. Codeunit 226 (`CustEntry-Apply Posted Entries`) handles customer applications; codeunit 227 (`VendEntry-Apply Posted Entries`) handles vendors. Both route through codeunit 12 for the G/L posting and write Detailed Cust./Vendor Ledger Entries that preserve the application history. - -Two tolerances add flexibility. Payment Discount gives a counterparty a reduced amount if they pay within a grace window; when the payment matches the discounted amount, the invoice closes and a discount-expense G/L Entry records the difference. Payment Tolerance lets a slightly short payment still close an invoice; the shortfall posts to a Payment Tolerance account. Both are configured on the Sales & Receivables Setup and Vendor Posting Groups; both can be disabled per customer/vendor. - -Applications across currencies trigger an exchange-rate adjustment inside the application itself: the FCY amounts apply directly, but the LCY equivalent of each leg may differ because the rates at posting dates differ. The difference posts to an exchange gain/loss account as part of the application, independent of the period-end Adjust Exchange Rates batch. - -## Best Practice - -Let the Apply action compute the amounts. Manually setting Amount to Apply on one side and letting the other auto-calculate produces rounding that can leave tiny (0.01) Remaining Amounts that block closing the period. - -## Anti Pattern - -Scripting direct updates to Remaining Amount to close out a balance. The Detailed Cust./Vendor Ledger Entry chain no longer matches and the entry, while appearing closed, cannot be un-applied or reversed cleanly. - -## Provenance - -Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Entry Application") on 2026-04-21. diff --git a/microsoft/knowledge/finance/exchange-rate-adjustment.md b/microsoft/knowledge/finance/exchange-rate-adjustment.md deleted file mode 100644 index 610b672..0000000 --- a/microsoft/knowledge/finance/exchange-rate-adjustment.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [1..99] -domain: finance -keywords: [exchange-rate, adjust-exchange-rate, report-595, detailed-ledger-entry, unrealized-gain-loss] -technologies: [al] -countries: [w1] -application-area: [finance] ---- - -# Exchange rate adjustment - -## Description - -Open foreign-currency ledger entries accumulate unrealized gain or loss as the exchange rate drifts from the posting-date rate. Report 595 (`Adjust Exchange Rates`) is the period-end batch job that revalues every open customer, vendor, bank, and G/L entry against the rate at the adjustment date. For each entry, it computes the rate delta, posts a Detailed Cust./Vendor Ledger Entry (or G/L Entry for bank and G/L accounts) that brings the LCY value back in line, and posts the offset to the configured Unrealized Gains/Unrealized Losses account. The next run reverses the prior adjustment before posting a new one, so the unrealized accounts only ever carry the current-period difference. - -Running this batch is the hinge between period-end reporting and correct FCY balances. Skipping a period leaves the LCY equivalent of open balances stale; the next run has to absorb two periods of drift into one, producing a large unrealized swing that auditors flag. Running it twice in the same period on the same data is safe — the reversal mechanism makes the operation idempotent as long as the rate table has not changed. - -## Best Practice - -Schedule the batch as part of the month-end close, after posting the last FCY transactions and before freezing the period. Store the Exchange Rate table values the batch used so that re-running against "today's rates" later can be reconciled against the month-end snapshot. - -## Anti Pattern - -Running the batch without first verifying that the Currency Exchange Rate table has entries for the adjustment date. BC silently uses the most recent earlier entry, which on a missing-rate day can be weeks stale and produces an unrealized swing with no economic meaning. - -## Provenance - -Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Multi-Currency — Rounding and Exchange Rates") on 2026-04-21. diff --git a/microsoft/knowledge/finance/general-journal-posting.md b/microsoft/knowledge/finance/general-journal-posting.md deleted file mode 100644 index 0cd139a..0000000 --- a/microsoft/knowledge/finance/general-journal-posting.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [1..99] -domain: finance -keywords: [general-journal, journal-line, journal-posting, journal-types, gen-jnl-post-line] -technologies: [al] -countries: [w1] -application-area: [finance] ---- - -# General journal posting - -## Description - -Journal posting is the freeform pathway into G/L. A user fills lines in a journal batch (table 81 — `Gen. Journal Line`) and runs post; codeunit 12 (`Gen. Jnl.-Post Line`) processes each line into the appropriate ledger entries. The same table serves several journal types, distinguished by their template/batch combination: General (generic postings), Payment (outgoing payments with applying logic), Cash Receipt (incoming payments with applying logic), Recurring (allocations and accruals with date formulas), and IC General (intercompany variants that replicate to partner companies). - -Before the post, codeunit 13 (`Gen. Jnl.-Check Line`) validates each line — balancing, dimensions, account existence, posting restrictions. A failure there halts the entire batch; partial posts are not possible within a balanced transaction set. A single journal batch may contain many balanced transactions; each transaction is identified by a matching Document No. and must balance to zero across debits and credits. - -## Best Practice - -Separate unrelated postings into distinct balanced transactions (distinct Document No. values) within the batch. This makes a failure easier to locate and lets un-failed transactions still post if the check is run line-by-line. - -## Anti Pattern - -Stacking many unrelated postings under one Document No. A single validation error then blocks everything and the user must hunt for the offending line inside the balanced group. - -## Provenance - -Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Chart of Accounts & G/L Posting") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Finance\`. diff --git a/microsoft/knowledge/finance/general-ledger-entries.md b/microsoft/knowledge/finance/general-ledger-entries.md deleted file mode 100644 index 6361616..0000000 --- a/microsoft/knowledge/finance/general-ledger-entries.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [1..99] -domain: finance -keywords: [gl-entry, ledger, posting, subledger, immutable] -technologies: [al] -countries: [w1] -application-area: [finance] ---- - -# General Ledger entries - -## Description - -G/L Entries (table 17) are the ultimate destination of every monetary posting in Business Central. Every sub-ledger entry — Customer Ledger Entry (21), Vendor Ledger Entry (25), Item Ledger Entry (32), Fixed Asset Ledger Entry (5601), Bank Account Ledger Entry (271) — produces corresponding G/L Entries that update account balances. The sub-ledgers exist to carry dimension-specific analytical data (due date, item number, reservation); G/L Entries are the canonical financial record. - -G/L Entries are immutable. Reversing a mistake requires a corrective posting (often via `Reverse` on the original entry), not modification. The Entry No. column is monotonically increasing, so applications ordering entries by Entry No. see insertion order; they should not assume any relationship between Entry No. and Posting Date. - -## Best Practice - -When reading G/L Entries in a report, filter on Posting Date and Global Dimension 1/2 Code rather than on numeric Entry No. ranges — ranges are not stable across companies and break when entries are reversed and re-posted. - -## Anti Pattern - -Modifying G/L Entry columns directly in custom code to "fix" a posting error. The sub-ledger entries and supporting tables (Detailed Cust./Vendor Ledger Entry, VAT Entry) remain unchanged and diverge from G/L, producing an off-balance state that only surfaces at period-close reconciliation. - -## Provenance - -Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Chart of Accounts & G/L Posting") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Finance\`. diff --git a/microsoft/knowledge/finance/multi-currency-rounding.md b/microsoft/knowledge/finance/multi-currency-rounding.md deleted file mode 100644 index a0de878..0000000 --- a/microsoft/knowledge/finance/multi-currency-rounding.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [1..99] -domain: finance -keywords: [multi-currency, rounding, currency-precision, invoice-rounding, fcy-lcy] -technologies: [al] -countries: [w1] -application-area: [finance] ---- - -# Multi-currency rounding - -## Description - -Every Currency record (table 4) declares four rounding precisions that govern how BC handles foreign-currency (FCY) amounts: Amount Rounding Precision (typically 0.01), Unit-Amount Rounding Precision (typically 0.00001, used for unit prices), Invoice Rounding Precision (typically 0.01, the tolerance that lets an invoice round to a "clean" final amount), and Appln. Rounding Precision (tolerance for closing applications across currencies). The four precisions interact with the local currency's precision to determine what amounts a document ends up posting. - -At post time the codeunit computes three amounts per line: the FCY amount (rounded to Amount Rounding Precision), the LCY amount (FCY × exchange rate, rounded to the local currency's precision), and any residual that falls to the Invoice Rounding account configured on the Customer/Vendor Posting Group. Rounding conflicts appear when: (a) LCY precision is coarser than FCY — JPY bookkeeping with EUR documents rounds to whole yen but allows 0.01 EUR; (b) per-line rounding on a many-line document diverges from single-line rounding of the document total; (c) the exchange rate changes between an order's receipt and its invoice, and the rounding residual shifts. - -## Best Practice - -Use the Invoice Rounding account purposefully — set it to a dedicated G/L account so the residuals aggregate where finance can review them. A catch-all "other income" lumps them with real transactions and hides rounding drift. - -## Anti Pattern - -Disabling Invoice Rounding by setting the precision to 0. The residuals then split across every VAT and payment account, making period-end reconciliation a hunt for pennies that do not belong to any transaction. - -## Provenance - -Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Multi-Currency — Rounding and Exchange Rates") on 2026-04-21. diff --git a/microsoft/knowledge/finance/unrealized-vat.md b/microsoft/knowledge/finance/unrealized-vat.md deleted file mode 100644 index 57cf7e9..0000000 --- a/microsoft/knowledge/finance/unrealized-vat.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [1..99] -domain: finance -keywords: [unrealized-vat, vat-realization, payment-application, vat-entry, deferred-recognition] -technologies: [al] -countries: [w1] -application-area: [finance] ---- - -# Unrealized VAT - -## Description - -Unrealized VAT defers the VAT liability to the moment payment settles rather than the moment the invoice posts. When enabled on a VAT Posting Setup cell, posting the invoice creates a VAT Entry with a zero amount in the Amount column and the full amount in Unrealized Amount. When a payment applies to the invoice via codeunit 226 (`CustEntry-Apply Posted Entries`) or 227 (`VendEntry-Apply Posted Entries`), additional VAT Entries are created that move the amount from Unrealized to Realized in proportion to the payment applied. Partial payments realize partial VAT. - -This matters for three reasons. First, the VAT return runs on realized entries only, so the period the liability is declared depends on payment date, not invoice date. Second, the chain of VAT Entries grows: one per application event. Third, reversing an application (un-applying entries) creates mirror VAT Entries that reverse the realization — never edit existing entries. The mechanism is well-defined; bugs usually stem from assumptions that VAT always realizes at posting. - -## Best Practice - -When migrating a company onto Unrealized VAT, take the effective-date approach: new invoices carry the new setup, historical open invoices post-realize at payment under the old setup. Mixing both setups on the same open invoice produces a VAT Entry chain that does not balance. - -## Anti Pattern - -Expecting the VAT account to equal the invoice's VAT amount immediately after the invoice posts. Under Unrealized VAT, the account is zero until the first payment applies. Reports that compare invoice VAT to G/L VAT balance must filter by realization state or they report every unpaid invoice as an error. - -## Provenance - -Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "VAT Calculation") on 2026-04-21. diff --git a/microsoft/knowledge/finance/vat-on-prepayment-chains.md b/microsoft/knowledge/finance/vat-on-prepayment-chains.md deleted file mode 100644 index 5d60ddd..0000000 --- a/microsoft/knowledge/finance/vat-on-prepayment-chains.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [1..99] -domain: finance -keywords: [vat-prepayment, prepayment-chain, credit-memo, rounding, proportional-adjustment] -technologies: [al] -countries: [w1] -application-area: [finance] ---- - -# VAT on prepayment chains - -## Description - -When a Sales or Purchase document carries a prepayment percentage, Business Central splits VAT across the prepayment and the final invoice. The prepayment invoice posts VAT on the prepayment percentage of the order; the final invoice posts VAT on the remaining portion and contains a deduction line that reverses the prepayment's VAT share. If a credit memo reverses either leg, its VAT must proportion across whatever has already posted. This chain — prepayment invoice → final invoice → optional credit memo — must reconcile to the same VAT amount a one-shot invoice would have produced. - -The chain is rounding-sensitive: each leg rounds independently per the VAT posting setup, and the sum of rounded legs can differ from rounding the total once. In multi-currency chains, each leg may use a different exchange rate (posting date differs), further complicating reconciliation. Symptom: the VAT account carries a 0.01 or 0.02 residual after all legs post; no single posting caused it, but the chain does not balance to the expected single-invoice equivalent. - -## Best Practice - -Let Business Central compute and post the VAT on every leg rather than overriding it. The proportional-adjustment logic inside codeunit 80/90 expects to own these amounts; manual overrides produce residuals that only surface at VAT return time. - -## Anti Pattern - -Correcting a prepayment-chain mismatch by modifying the VAT Entry on the final invoice. The entry is linked to the G/L Entry and the sales invoice line; editing it desynchronises the three and the VAT return aggregates the wrong number. Post a corrective document instead. - -## Provenance - -Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "VAT Calculation" and "VAT Edge Cases That Cause Triage Issues") on 2026-04-21. diff --git a/microsoft/knowledge/finance/vat-posting-setup.md b/microsoft/knowledge/finance/vat-posting-setup.md deleted file mode 100644 index 6a430f6..0000000 --- a/microsoft/knowledge/finance/vat-posting-setup.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [1..99] -domain: finance -keywords: [vat, vat-posting-setup, vat-business-group, vat-product-group, reverse-charge, full-vat] -technologies: [al] -countries: [w1] -application-area: [finance] ---- - -# VAT posting setup - -## Description - -VAT Posting Setup (table 325) is the matrix that tells Business Central how to compute VAT for every combination of VAT Business Posting Group (who the counterparty is — domestic, EU, export) and VAT Product Posting Group (what is being transacted — standard goods, reduced-rate goods, exempt services). Each cell of the matrix declares the VAT % and the VAT Calculation Type that applies when that combination appears on a posting. - -Three calculation types cover the common cases. Normal VAT applies the rate as a percentage of the line amount — the standard sales/purchase tax path. Reverse Charge VAT records the VAT on both sides of the transaction without a cash movement; the buyer, not the seller, is responsible for remitting it to the authority. Full VAT treats the entire line amount as VAT with no underlying taxable base — used for VAT-only correction documents. Every posted line in the document flows through the matching cell; misconfigured cells produce posting errors, incorrect returns, or off-balance VAT accounts. - -## Best Practice - -Populate the full matrix, including "not applicable" cells (with zero rate and a note). Missing cells produce an error message that names the combination the user tried to use, which is clearer than an unexpected zero-rate post that would mask the misconfiguration. - -## Anti Pattern - -Creating a single catch-all VAT Business Group for "everyone" and a single VAT Product Group for "everything." Reporting the VAT return later becomes impossible because every transaction collapses into one cell; the Authority requires transaction-level breakdown. - -## Provenance - -Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "VAT Calculation") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Finance\VAT\`. diff --git a/microsoft/skills/bc-domain-context.md b/microsoft/skills/bc-domain-context.md deleted file mode 100644 index 43414fb..0000000 --- a/microsoft/skills/bc-domain-context.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -kind: action-skill -id: bc-domain-context -version: 1 -title: BC domain context -description: Returns Business Central domain-knowledge references for a task's application area. -inputs: [file-path, repository] -outputs: [findings-report] -bc-version: [1..99] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# BC domain context - -Surfaces the Business Central domain-knowledge files that apply to a task's application area. This is a leaf action skill — it invokes no sub-skills and produces informational findings that cite the relevant knowledge files. Consumers that need to reason about a BC module (triage bots, code assistants, review helpers) invoke this skill, read the cited files, and bring that content into their own context. - -The skill produces a single JSON document conforming to the DO output contract. - -## Source - -Collect knowledge files under `*/knowledge//**/*.md` for every value in `task-context.application-area`, across every enabled layer (`/microsoft/`, `/community/`, `/custom/`). When `application-area` is absent, empty, or `[all]`, source from every area-named knowledge folder across the enabled layers — the full domain corpus. - -## Relevance - -Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context: - -- `bc-version` — match the task's BC version. If the orchestrator did not supply one, the dimension is `unknown`. -- `technologies` — `[al]`. Knowledge files that declare other technologies (e.g. `[powershell]`) must still intersect with `[al]`; discard those that do not. -- `countries` — `[w1]` matches any task context; country-specific files match only when the task-context `countries` overlaps the file's declared countries. -- `application-area` — the file's `application-area` must include every value the task supplied, OR be `[all]`. - -Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the `message` MUST name the dimensions that were unknown. - -## Worklist - -Narrow the relevant set to the files that will be cited: - -1. **Goal-directed narrowing.** When `task-context.goal` contains concrete domain tokens beyond the `bc-domain-context for ` prefix (for example, *"VAT on prepayment credit memo"*, *"flushing method scrap"*, *"warehouse directed pick"*), score each candidate's `keywords`, filename, and `## Description` content against those tokens. Keep the highest-scoring 15 files. Ties are broken by keyword-overlap count, then filename specificity. - -2. **Full-area fallback.** When the goal contains no tokens beyond the prefix (the consumer wants the whole area), skip scoring and keep every relevant file. - -3. **Layer precedence.** Resolve conflicts per READ: `/custom/` wins over `/microsoft/`, `/microsoft/` wins over `/community/`. For knowledge files sharing the same `/.md` path across layers, keep the highest-precedence file and record each suppressed file in `suppressed[]` with `reason: "layer-precedence"`. Files hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`. - -If the post-conflict worklist is empty because no area knowledge applies to the task, emit `outcome: "no-knowledge"`. If the relevance filter ruled out every file because of a mismatch (e.g. the task targets a BC version no file supports), emit `outcome: "not-applicable"`. - -## Action - -For each worklist file, emit one finding: - -- `id` — the file's repo-relative path (per DO, citation-based findings use the primary reference path as the id). -- `severity` — `info`. This skill never blocks; it is purely informational. -- `message` — the file's H1 title followed by the first two to three sentences of its `## Description` section. Strip leading whitespace and heading markers. When any frontmatter dimension was `unknown` during Relevance, append `" (conditional on: )"` to the message. -- `location` — omitted. Findings from this skill are not tied to a source-code location. -- `references` — a single reference object: `{ "path": "", "sha": "" }`. Include `sha` when the consumer invoked the skill against a specific BCQuality commit. -- `confidence` — `high` when every frontmatter dimension matched exactly; `medium` when any dimension was `unknown`. - -Populate `summary.counts` with every emitted finding counted as `info`. Populate `summary.coverage` with `worklist-size` and `items-evaluated` — both equal the number of worklist files when the skill finishes normally. - -## Output - -Conforms to the DO output contract. A populated example for a finance-area task: - -```json -{ - "skill": { "id": "bc-domain-context", "version": 1 }, - "outcome": "completed", - "summary": { - "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 13 }, - "coverage": { "worklist-size": 13, "items-evaluated": 13 } - }, - "findings": [ - { - "id": "microsoft/knowledge/finance/vat-on-prepayment-chains.md", - "severity": "info", - "message": "VAT on prepayment chains. The VAT amount on a prepayment invoice is computed on the prepayment percentage, then adjusted when the final invoice posts and again when a credit memo reverses either leg. Each step must reconcile against the sales-header prepayment account to avoid rounding drift.", - "references": [ - { "path": "microsoft/knowledge/finance/vat-on-prepayment-chains.md" } - ], - "confidence": "high" - } - ], - "suppressed": [] -} -``` - -The empty-corpus case — the state before any area knowledge lands in BCQuality — produces: - -```json -{ - "skill": { "id": "bc-domain-context", "version": 1 }, - "outcome": "no-knowledge", - "summary": { - "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 }, - "coverage": { "worklist-size": 0, "items-evaluated": 0 } - }, - "findings": [], - "suppressed": [] -} -``` From 5a02e6ec930613be12335e59e55d8054f4981866 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 23 Apr 2026 12:54:10 +0200 Subject: [PATCH 04/15] Add warning for active development status Added a warning about active development and upcoming preview. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index ed222d7..882e1d1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +# ⚠️ Warning +This project is under active development. +Large and potentially breaking changes are expected. + +**Public preview will soon be announced.** + # BCQuality Quality skills and knowledge for Business Central development. From 23184480d06e9fc9e029b69d685d5717d7ef03bb Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 23 Apr 2026 15:47:01 +0200 Subject: [PATCH 05/15] Triage seed knowledge and document admission test for preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove seven knowledge files whose content is generic software-engineering guidance that a capable LLM already applies without BCQuality present (HTTPS-only, secret-leakage-in-errors, no-credentials-in-URLs, silent security-error swallowing, short transaction scope, HTTP timeouts, StrSubstNo-vs-concatenation). These fail the remedial-knowledge premise and dilute the signal of the preview corpus. Strip the "Seed article — domain stewards should expand" banner from ten files that are ready to showcase (AA0232/AA0233 rules, FindSet read-only semantics, SetLoadFields ordering and usage, CalcFields-in-loops, SecretText end-to-end, DataClassification). The banner remains on files that still need domain-steward refinement. Add a "What belongs here" section to the README stating the admission test: a file exists only if a modern LLM would get something wrong or miss something without it. Gives contributors a concrete yes/no filter before they open a PR. --- README.md | 14 +++++++++ .../call-setloadfields-before-filters.md | 2 -- ...ify-every-field-with-dataclassification.md | 2 -- .../add-sift-keys-for-flowfields.md | 2 -- .../performance/avoid-calcfields-in-loops.md | 2 -- .../performance/avoid-findfirst-with-next.md | 2 -- .../performance/filter-before-find.md | 2 -- .../keep-transaction-scope-short.bad.al | 18 ------------ .../keep-transaction-scope-short.good.al | 22 -------------- .../keep-transaction-scope-short.md | 29 ------------------- .../use-findset-readonly-by-default.md | 2 -- .../use-setloadfields-for-partial-records.md | 2 -- ...e-strsubstno-for-message-formatting.bad.al | 7 ----- ...-strsubstno-for-message-formatting.good.al | 9 ------ .../use-strsubstno-for-message-formatting.md | 29 ------------------- ...id-sensitive-data-in-error-messages.bad.al | 14 --------- ...d-sensitive-data-in-error-messages.good.al | 24 --------------- .../avoid-sensitive-data-in-error-messages.md | 29 ------------------- .../do-not-put-credentials-in-urls.bad.al | 10 ------- .../do-not-put-credentials-in-urls.good.al | 13 --------- .../do-not-put-credentials-in-urls.md | 29 ------------------- ...ot-swallow-security-errors-silently.bad.al | 15 ---------- ...t-swallow-security-errors-silently.good.al | 24 --------------- ...do-not-swallow-security-errors-silently.md | 29 ------------------- .../require-https-for-external-calls.bad.al | 10 ------- .../require-https-for-external-calls.good.al | 12 -------- .../require-https-for-external-calls.md | 29 ------------------- .../set-timeouts-for-external-calls.bad.al | 11 ------- .../set-timeouts-for-external-calls.good.al | 12 -------- .../set-timeouts-for-external-calls.md | 29 ------------------- .../use-secrettext-for-credentials.md | 2 -- .../use-secrettext-with-httpclient.md | 2 -- 32 files changed, 14 insertions(+), 424 deletions(-) delete mode 100644 microsoft/knowledge/performance/keep-transaction-scope-short.bad.al delete mode 100644 microsoft/knowledge/performance/keep-transaction-scope-short.good.al delete mode 100644 microsoft/knowledge/performance/keep-transaction-scope-short.md delete mode 100644 microsoft/knowledge/performance/use-strsubstno-for-message-formatting.bad.al delete mode 100644 microsoft/knowledge/performance/use-strsubstno-for-message-formatting.good.al delete mode 100644 microsoft/knowledge/performance/use-strsubstno-for-message-formatting.md delete mode 100644 microsoft/knowledge/security/avoid-sensitive-data-in-error-messages.bad.al delete mode 100644 microsoft/knowledge/security/avoid-sensitive-data-in-error-messages.good.al delete mode 100644 microsoft/knowledge/security/avoid-sensitive-data-in-error-messages.md delete mode 100644 microsoft/knowledge/security/do-not-put-credentials-in-urls.bad.al delete mode 100644 microsoft/knowledge/security/do-not-put-credentials-in-urls.good.al delete mode 100644 microsoft/knowledge/security/do-not-put-credentials-in-urls.md delete mode 100644 microsoft/knowledge/security/do-not-swallow-security-errors-silently.bad.al delete mode 100644 microsoft/knowledge/security/do-not-swallow-security-errors-silently.good.al delete mode 100644 microsoft/knowledge/security/do-not-swallow-security-errors-silently.md delete mode 100644 microsoft/knowledge/security/require-https-for-external-calls.bad.al delete mode 100644 microsoft/knowledge/security/require-https-for-external-calls.good.al delete mode 100644 microsoft/knowledge/security/require-https-for-external-calls.md delete mode 100644 microsoft/knowledge/security/set-timeouts-for-external-calls.bad.al delete mode 100644 microsoft/knowledge/security/set-timeouts-for-external-calls.good.al delete mode 100644 microsoft/knowledge/security/set-timeouts-for-external-calls.md diff --git a/README.md b/README.md index 882e1d1..ebed31b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,20 @@ Quality skills and knowledge for Business Central development. BCQuality is a curated knowledge base and skills library for Business Central. It provides structured, machine-readable guidance that development agents and tools can consume — establishing a consistent quality bar across tooling and teams. +## What belongs here + +BCQuality is a remedial knowledge base. A file exists because a capable LLM **would get something wrong, or miss something, without it** — not because the topic is important. The admission test for a knowledge file is one question: + +> If this file did not exist, would a modern LLM reviewing or generating BC code make a mistake this file would have prevented? + +If the answer is no — the advice is generic software-engineering guidance, or the LLM already knows the BC mechanic in question — the file does not belong here, regardless of how sound the content is. A file earns its place by encoding something BC-specific that LLMs demonstrably get wrong: a CodeCop rule number, a platform API whose semantics the training data gets backwards, a non-obvious ordering rule, a BC property whose default is a footgun. + +Good fit: "`SetLoadFields` must be called before filters, not after" (non-obvious ordering rule). "`FindSet(true)` takes a LockTable and the two-parameter signature is obsolete" (subtle platform behaviour + outdated training data). "CodeCop AA0233 flags `FindFirst … Next` loops" (rule-specific). + +Poor fit: "Use HTTPS instead of HTTP." "Don't hardcode secrets." "Keep transactions short." These are true but any capable LLM already applies them without prompting. + +The practical consequence: when a code-review agent flags something it shouldn't have, or misses something it should have caught, the remedy is a new knowledge file. When it already behaves correctly on a topic, no file is needed. + ## What's in this repo BCQuality contains **knowledge** and **skills**. It does not contain agents. Agents that consume BCQuality ship with [AL-Go](https://github.com/microsoft/AL-Go) and other orchestrators. diff --git a/community/knowledge/performance/call-setloadfields-before-filters.md b/community/knowledge/performance/call-setloadfields-before-filters.md index 90d95e8..78b455b 100644 --- a/community/knowledge/performance/call-setloadfields-before-filters.md +++ b/community/knowledge/performance/call-setloadfields-before-filters.md @@ -9,8 +9,6 @@ application-area: [all] # Call SetLoadFields before filters -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. - ## Description `SetLoadFields` is folded into the database query that the subsequent `Find`, `FindSet`, or `FindFirst` executes. When it is called after filters have already been applied, the platform either ignores the specification or is forced into an extra round-trip to reload the narrower column set — negating the optimization. The placement rule is simple and absolute: `SetLoadFields` must come first. diff --git a/community/knowledge/security/classify-every-field-with-dataclassification.md b/community/knowledge/security/classify-every-field-with-dataclassification.md index 540f64b..79c27b1 100644 --- a/community/knowledge/security/classify-every-field-with-dataclassification.md +++ b/community/knowledge/security/classify-every-field-with-dataclassification.md @@ -9,8 +9,6 @@ application-area: [all] # Classify every field with DataClassification -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. - ## Description Every field on every AL table and table extension must carry an explicit `DataClassification` property. The value drives GDPR tooling, data-subject requests, retention policies, and audit reporting — all of which rely on the field metadata to know what data to include, anonymize, or delete. A field with no `DataClassification` defaults to `ToBeClassified`, which is a compliance gap, not a neutral state. diff --git a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md b/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md index e72eb2a..b2dce78 100644 --- a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md +++ b/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md @@ -9,8 +9,6 @@ application-area: [all] # Add SIFT keys for FlowField aggregations -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - ## Description CodeCop rule AA0232 checks that FlowFields backed by CalcSums or aggregation CalcFormula are supported by a key whose SumIndexFields include the summed field and whose key prefix matches the formula's filter fields. Without a SIFT key the platform falls back to a full aggregation on every read — typically invisible in development and catastrophic in production. diff --git a/microsoft/knowledge/performance/avoid-calcfields-in-loops.md b/microsoft/knowledge/performance/avoid-calcfields-in-loops.md index 1be8cfa..c89509a 100644 --- a/microsoft/knowledge/performance/avoid-calcfields-in-loops.md +++ b/microsoft/knowledge/performance/avoid-calcfields-in-loops.md @@ -9,8 +9,6 @@ application-area: [all] # Do not call CalcFields inside loops -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - ## Description CalcFields evaluates one or more FlowFields for the current record by issuing a separate SQL aggregation. Called inside a loop over a record set, it becomes an N+1 problem: one aggregate per row. For any non-trivial set on a ledger-entry-backed FlowField this is orders of magnitude slower than the equivalent batched query. diff --git a/microsoft/knowledge/performance/avoid-findfirst-with-next.md b/microsoft/knowledge/performance/avoid-findfirst-with-next.md index bdff419..92fe53f 100644 --- a/microsoft/knowledge/performance/avoid-findfirst-with-next.md +++ b/microsoft/knowledge/performance/avoid-findfirst-with-next.md @@ -9,8 +9,6 @@ application-area: [all] # Do not pair FindFirst, FindLast, or Get with Next -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - ## Description CodeCop rule AA0233 flags loops that start with FindFirst, FindLast, or Get and then call Next. FindFirst and FindLast retrieve a single row and reposition the cursor; calling Next after them forces the platform to re-seek and stream the rest of the set, which is slower than the correct FindSet pattern and signals intent incorrectly to reviewers and the optimizer. diff --git a/microsoft/knowledge/performance/filter-before-find.md b/microsoft/knowledge/performance/filter-before-find.md index 94389b4..271a544 100644 --- a/microsoft/knowledge/performance/filter-before-find.md +++ b/microsoft/knowledge/performance/filter-before-find.md @@ -9,8 +9,6 @@ application-area: [all] # Filter before you find -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - ## Description Every call to FindSet, Find, or FindFirst on an unfiltered record variable scans the entire table. On hot tables (ledger entries, value entries, sales invoice lines) a production dataset can easily be millions of rows, so the cost of forgetting a filter is orders of magnitude worse than the cost of applying one. diff --git a/microsoft/knowledge/performance/keep-transaction-scope-short.bad.al b/microsoft/knowledge/performance/keep-transaction-scope-short.bad.al deleted file mode 100644 index 76c7972..0000000 --- a/microsoft/knowledge/performance/keep-transaction-scope-short.bad.al +++ /dev/null @@ -1,18 +0,0 @@ -codeunit 50128 "Perf Sample TxnScope Bad" -{ - procedure ImportCustomers(var Source: List of [Text]) - var - Customer: Record Customer; - HttpClient: HttpClient; - HttpResponse: HttpResponseMessage; - Row: Text; - begin - foreach Row in Source do begin - // external call inside the write transaction - HttpClient.Get('https://example.com/validate?row=' + Row, HttpResponse); - Customer.Init(); - // ... populate from Row ... - Customer.Insert(true); - end; - end; -} diff --git a/microsoft/knowledge/performance/keep-transaction-scope-short.good.al b/microsoft/knowledge/performance/keep-transaction-scope-short.good.al deleted file mode 100644 index c875f39..0000000 --- a/microsoft/knowledge/performance/keep-transaction-scope-short.good.al +++ /dev/null @@ -1,22 +0,0 @@ -codeunit 50123 "Perf Sample TxnScope Good" -{ - procedure ImportCustomers(var Source: List of [Text]) - var - Prepared: Record Customer temporary; - Customer: Record Customer; - begin - // read, validate, and shape outside the transaction - PrepareRows(Source, Prepared); - - // transaction starts here: only Insert/Modify calls - if Prepared.FindSet() then - repeat - Customer := Prepared; - Customer.Insert(true); - until Prepared.Next() = 0; - end; - - local procedure PrepareRows(var Source: List of [Text]; var Prepared: Record Customer temporary) - begin - end; -} diff --git a/microsoft/knowledge/performance/keep-transaction-scope-short.md b/microsoft/knowledge/performance/keep-transaction-scope-short.md deleted file mode 100644 index 704cffc..0000000 --- a/microsoft/knowledge/performance/keep-transaction-scope-short.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [transaction, lock, scope, contention] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Keep transaction scope short - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Every write operation runs inside a transaction that holds locks until the transaction ends. Long transactions increase blocking, deadlocks, and timeouts for other sessions. The same work split across narrower transactions typically completes faster under load because it holds locks for less time. - -## Best Practice - -Perform data reads, calculations, and external integrations outside the transaction whenever possible. Enter the writing phase with all inputs computed, execute the minimum set of Insert, Modify, and Delete calls, and exit. If you have a long-running batch, split it into checkpoints at safe boundaries (see avoid-commit-inside-loops). - -See sample: `keep-transaction-scope-short.good.al`. - -## Anti Pattern - -Opening a transaction, then performing external web-service calls, heavy report runs, or user-facing dialogs while the locks are held, suspends every other session that needs the same rows for as long as the external operation takes. - -See sample: `keep-transaction-scope-short.bad.al`. - diff --git a/microsoft/knowledge/performance/use-findset-readonly-by-default.md b/microsoft/knowledge/performance/use-findset-readonly-by-default.md index 64cbe72..e3d3bba 100644 --- a/microsoft/knowledge/performance/use-findset-readonly-by-default.md +++ b/microsoft/knowledge/performance/use-findset-readonly-by-default.md @@ -9,8 +9,6 @@ application-area: [all] # Use FindSet in read-only mode by default -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - ## Description FindSet has two modes: FindSet() and FindSet(false) are read-only and take no write lock; FindSet(true) calls LockTable before fetching. Write locks are expensive and hold for the remainder of the transaction, so passing `true` when you do not intend to modify the records increases contention under load. diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md index e112fc7..bcbf064 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -9,8 +9,6 @@ application-area: [all] # Use SetLoadFields for partial records -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - ## Description SetLoadFields instructs the platform to hydrate only the listed fields on a record variable. On wide tables, or tables with BLOB or media fields, the difference is substantial: a Sales Invoice Line has dozens of fields and loading all of them for every row of a large set is wasted bandwidth. Primary key fields, SystemId, and system audit fields are always loaded automatically. SetLoadFields works only with FieldClass = Normal; FlowFields and FlowFilters cannot be partial-loaded. diff --git a/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.bad.al b/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.bad.al deleted file mode 100644 index bcfa7d0..0000000 --- a/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.bad.al +++ /dev/null @@ -1,7 +0,0 @@ -codeunit 50137 "Perf Sample StrSubstNo Bad" -{ - procedure CustomerGreeting(var Customer: Record Customer): Text - begin - exit('Hello, ' + Customer.Name + ' (' + Customer."No." + ')'); - end; -} diff --git a/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.good.al b/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.good.al deleted file mode 100644 index 87883e8..0000000 --- a/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.good.al +++ /dev/null @@ -1,9 +0,0 @@ -codeunit 50136 "Perf Sample StrSubstNo Good" -{ - procedure CustomerGreeting(var Customer: Record Customer): Text - var - GreetingLbl: Label 'Hello, %1 (%2)'; - begin - exit(StrSubstNo(GreetingLbl, Customer.Name, Customer."No.")); - end; -} diff --git a/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.md b/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.md deleted file mode 100644 index 6e2b3f6..0000000 --- a/microsoft/knowledge/performance/use-strsubstno-for-message-formatting.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: performance -keywords: [strsubstno, string, concatenation, format] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use StrSubstNo for message formatting - -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -StrSubstNo formats values into a placeholder template in a single call. Manual concatenation with `+` produces a chain of intermediate strings, each allocated and discarded, and mixes formatting rules inconsistently across locales. The performance difference per call is small; repeated inside a tight loop it is noticeable. - -## Best Practice - -Declare the template as a Label (so it can be localized) and format with StrSubstNo. Pass values in the order the placeholders expect; StrSubstNo handles locale-sensitive conversions consistently. - -See sample: `use-strsubstno-for-message-formatting.good.al`. - -## Anti Pattern - -Building a user-facing string by concatenating record field values with string literals ignores locale rules and allocates more than necessary. - -See sample: `use-strsubstno-for-message-formatting.bad.al`. - diff --git a/microsoft/knowledge/security/avoid-sensitive-data-in-error-messages.bad.al b/microsoft/knowledge/security/avoid-sensitive-data-in-error-messages.bad.al deleted file mode 100644 index 0a78e75..0000000 --- a/microsoft/knowledge/security/avoid-sensitive-data-in-error-messages.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50225 "Sec Sample ErrorDisclosure Bad" -{ - procedure Connect() - begin - if not TryConnect() then - Error('Failed to connect to Server=PROD-SQL01;Database=NAV;User=svc_admin: %1', GetLastErrorText()); - end; - - [TryFunction] - local procedure TryConnect() - begin - // ... - end; -} diff --git a/microsoft/knowledge/security/avoid-sensitive-data-in-error-messages.good.al b/microsoft/knowledge/security/avoid-sensitive-data-in-error-messages.good.al deleted file mode 100644 index 6fb6267..0000000 --- a/microsoft/knowledge/security/avoid-sensitive-data-in-error-messages.good.al +++ /dev/null @@ -1,24 +0,0 @@ -codeunit 50224 "Sec Sample ErrorDisclosure Good" -{ - var - ConnectionFailedErr: Label 'Connection to the external service failed. Contact your administrator.'; - - procedure Connect() - begin - if not TryConnect() then begin - LogConnectionFailure(GetLastErrorText()); - Error(ConnectionFailedErr); - end; - end; - - [TryFunction] - local procedure TryConnect() - begin - // ... - end; - - local procedure LogConnectionFailure(Detail: Text) - begin - // Route to controlled logging (Session.LogMessage, activity log, etc.). - end; -} diff --git a/microsoft/knowledge/security/avoid-sensitive-data-in-error-messages.md b/microsoft/knowledge/security/avoid-sensitive-data-in-error-messages.md deleted file mode 100644 index 75d78cb..0000000 --- a/microsoft/knowledge/security/avoid-sensitive-data-in-error-messages.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [error, disclosure, logging, label] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Avoid sensitive data in error messages - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Errors surfaced to end users are routinely forwarded to support systems, captured in bug reports, and exported to telemetry. Server names, database names, usernames, connection strings, file paths, and stack excerpts in an end-user error message leak infrastructure detail to untrusted consumers and help an attacker map the environment. - -## Best Practice - -Raise end-user errors using localized Labels that describe the condition without naming infrastructure. Emit the actual detail (exception text, endpoint, correlation id) through the application's internal logging channel, where audience and retention are controlled. - -See sample: `avoid-sensitive-data-in-error-messages.good.al`. - -## Anti Pattern - -Error('Failed to connect to Server=PROD-SQL01;Database=NAV;User=admin: %1', Ex.Message); — every support ticket now carries the server name, database name, and service account. - -See sample: `avoid-sensitive-data-in-error-messages.bad.al`. - diff --git a/microsoft/knowledge/security/do-not-put-credentials-in-urls.bad.al b/microsoft/knowledge/security/do-not-put-credentials-in-urls.bad.al deleted file mode 100644 index 12476bb..0000000 --- a/microsoft/knowledge/security/do-not-put-credentials-in-urls.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50223 "Sec Sample UrlCreds Bad" -{ - procedure Call(ApiKey: Text) - var - Client: HttpClient; - Response: HttpResponseMessage; - begin - Client.Get('https://api.example.com/v1/items?api_key=' + ApiKey, Response); - end; -} diff --git a/microsoft/knowledge/security/do-not-put-credentials-in-urls.good.al b/microsoft/knowledge/security/do-not-put-credentials-in-urls.good.al deleted file mode 100644 index c26dd29..0000000 --- a/microsoft/knowledge/security/do-not-put-credentials-in-urls.good.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50222 "Sec Sample UrlCreds Good" -{ - procedure Call(ApiKey: SecretText) - var - Client: HttpClient; - Response: HttpResponseMessage; - AuthHeader: SecretText; - begin - AuthHeader := SecretStrSubstNo('Bearer %1', ApiKey); - Client.DefaultRequestHeaders.Add('Authorization', AuthHeader); - Client.Get('https://api.example.com/v1/items', Response); - end; -} diff --git a/microsoft/knowledge/security/do-not-put-credentials-in-urls.md b/microsoft/knowledge/security/do-not-put-credentials-in-urls.md deleted file mode 100644 index dc3843e..0000000 --- a/microsoft/knowledge/security/do-not-put-credentials-in-urls.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [url, query-string, credentials, logging] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not put credentials in URLs - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -URL query strings and path segments are routinely captured in web-server access logs, browser history, proxy logs, platform telemetry, and exception traces. A credential placed anywhere in the URL therefore persists across systems the extension does not control, and is typically retained far longer than the secret's intended lifetime. - -## Best Practice - -Transport credentials in Authorization headers, carried as SecretText end-to-end (see use-secrettext-with-httpclient). Where the URI itself must carry a secret (for example, a pre-signed URL), build it with SecretStrSubstNo and pass it via SetSecretRequestUri so it is never materialized as Text. - -See sample: `do-not-put-credentials-in-urls.good.al`. - -## Anti Pattern - -Appending '?api_key=' + Key to a request URL, or embedding a token in a path segment, then calling HttpClient.Get with the resulting Text URL. - -See sample: `do-not-put-credentials-in-urls.bad.al`. - diff --git a/microsoft/knowledge/security/do-not-swallow-security-errors-silently.bad.al b/microsoft/knowledge/security/do-not-swallow-security-errors-silently.bad.al deleted file mode 100644 index 923df57..0000000 --- a/microsoft/knowledge/security/do-not-swallow-security-errors-silently.bad.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50227 "Sec Sample SwallowErr Bad" -{ - procedure Authenticate(): Boolean - begin - if not TryAuthenticate() then - exit(false); - exit(true); - end; - - [TryFunction] - local procedure TryAuthenticate() - begin - // ... - end; -} diff --git a/microsoft/knowledge/security/do-not-swallow-security-errors-silently.good.al b/microsoft/knowledge/security/do-not-swallow-security-errors-silently.good.al deleted file mode 100644 index 2887312..0000000 --- a/microsoft/knowledge/security/do-not-swallow-security-errors-silently.good.al +++ /dev/null @@ -1,24 +0,0 @@ -codeunit 50226 "Sec Sample SwallowErr Good" -{ - procedure Authenticate(): Boolean - begin - if TryAuthenticate() then - exit(true); - - LogAuthFailure(GetLastErrorText()); - exit(false); - end; - - [TryFunction] - local procedure TryAuthenticate() - begin - // ... - end; - - local procedure LogAuthFailure(Detail: Text) - begin - Session.LogMessage('SEC0001', 'Authentication failed', Verbosity::Warning, - DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, - 'Detail', Detail); - end; -} diff --git a/microsoft/knowledge/security/do-not-swallow-security-errors-silently.md b/microsoft/knowledge/security/do-not-swallow-security-errors-silently.md deleted file mode 100644 index fa4af42..0000000 --- a/microsoft/knowledge/security/do-not-swallow-security-errors-silently.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [tryfunction, logging, audit, error] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not swallow security errors silently - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -Authentication failures, permission denials, and unexpected error paths in security-relevant code are the signals a reviewer or incident responder needs to see. A TryFunction whose failure is ignored without logging turns an attack or a misconfiguration into silent bad behaviour: the call returns false, the caller moves on, and no record of the event survives. - -## Best Practice - -Use TryFunctions to contain errors around security-relevant work, but always log the failure (category, GetLastErrorText, and enough context to identify the operation) before deciding whether to surface a user-facing error. Never discard a caught security error without a trace. - -See sample: `do-not-swallow-security-errors-silently.good.al`. - -## Anti Pattern - -`if not TryAuthenticate() then exit;` with no logging and no user-facing error. An authentication-bypass attempt, a revoked credential, and a transient network glitch are now indistinguishable. - -See sample: `do-not-swallow-security-errors-silently.bad.al`. - diff --git a/microsoft/knowledge/security/require-https-for-external-calls.bad.al b/microsoft/knowledge/security/require-https-for-external-calls.bad.al deleted file mode 100644 index 4edf291..0000000 --- a/microsoft/knowledge/security/require-https-for-external-calls.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50219 "Sec Sample Https Bad" -{ - procedure CallExternal() - var - Client: HttpClient; - Response: HttpResponseMessage; - begin - Client.Get('http://api.example.com/data', Response); - end; -} diff --git a/microsoft/knowledge/security/require-https-for-external-calls.good.al b/microsoft/knowledge/security/require-https-for-external-calls.good.al deleted file mode 100644 index 6ae7639..0000000 --- a/microsoft/knowledge/security/require-https-for-external-calls.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50218 "Sec Sample Https Good" -{ - procedure CallExternal(Endpoint: Text) - var - Client: HttpClient; - Response: HttpResponseMessage; - begin - if not Endpoint.StartsWith('https://') then - Error('Only HTTPS endpoints are allowed.'); - Client.Get(Endpoint, Response); - end; -} diff --git a/microsoft/knowledge/security/require-https-for-external-calls.md b/microsoft/knowledge/security/require-https-for-external-calls.md deleted file mode 100644 index a026015..0000000 --- a/microsoft/knowledge/security/require-https-for-external-calls.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [https, httpclient, tls, plaintext] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Require HTTPS for external calls - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -HttpClient can issue requests over plaintext HTTP as easily as over HTTPS. A request sent over http:// is transmitted unencrypted, exposing the full URL (including query string), the request headers (including Authorization), and the bodies of both request and response to any on-path observer. This holds even when the payload itself is not marked sensitive — request signatures and session tokens are routinely captured and replayed. - -## Best Practice - -Call external services exclusively over https://. When the destination is configurable, validate at runtime that the scheme is https before issuing the request, and fail closed with a clear (non-disclosing) error otherwise. - -See sample: `require-https-for-external-calls.good.al`. - -## Anti Pattern - -Issuing HttpClient.Get('http://...'), or accepting an arbitrary user-supplied URL and passing it straight to HttpClient without scheme validation. - -See sample: `require-https-for-external-calls.bad.al`. - diff --git a/microsoft/knowledge/security/set-timeouts-for-external-calls.bad.al b/microsoft/knowledge/security/set-timeouts-for-external-calls.bad.al deleted file mode 100644 index 2068c1f..0000000 --- a/microsoft/knowledge/security/set-timeouts-for-external-calls.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50221 "Sec Sample Timeout Bad" -{ - procedure CallExternal() - var - Client: HttpClient; - Response: HttpResponseMessage; - begin - // No Timeout set; a hung endpoint stalls the caller. - Client.Get('https://api.example.com/data', Response); - end; -} diff --git a/microsoft/knowledge/security/set-timeouts-for-external-calls.good.al b/microsoft/knowledge/security/set-timeouts-for-external-calls.good.al deleted file mode 100644 index ad9910c..0000000 --- a/microsoft/knowledge/security/set-timeouts-for-external-calls.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50220 "Sec Sample Timeout Good" -{ - procedure CallExternal() - var - Client: HttpClient; - Response: HttpResponseMessage; - begin - Client.Timeout := 10000; // 10 seconds - if not Client.Get('https://api.example.com/data', Response) then - Error('External service is unavailable.'); - end; -} diff --git a/microsoft/knowledge/security/set-timeouts-for-external-calls.md b/microsoft/knowledge/security/set-timeouts-for-external-calls.md deleted file mode 100644 index 28d83b0..0000000 --- a/microsoft/knowledge/security/set-timeouts-for-external-calls.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [26..28] -domain: security -keywords: [timeout, httpclient, availability, dos] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Set timeouts for external calls - -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - -## Description - -An HttpClient with no explicit timeout relies on defaults that may be long enough for a hung or slow endpoint to block a user session or a background task for minutes. A dependency that degrades therefore degrades the caller, and an intentionally slow endpoint is a cheap denial-of-service vector against the extension. - -## Best Practice - -Set HttpClient.Timeout to a bounded value (seconds, not minutes) that reflects the SLA of the dependency. Handle the timeout error without leaking endpoint details to end users (see avoid-sensitive-data-in-error-messages). - -See sample: `set-timeouts-for-external-calls.good.al`. - -## Anti Pattern - -Issuing HttpClient requests without setting Timeout and without a timeout-handling branch. A slow dependency now has an unbounded blast radius inside the extension. - -See sample: `set-timeouts-for-external-calls.bad.al`. - diff --git a/microsoft/knowledge/security/use-secrettext-for-credentials.md b/microsoft/knowledge/security/use-secrettext-for-credentials.md index 991f2d0..0401d9a 100644 --- a/microsoft/knowledge/security/use-secrettext-for-credentials.md +++ b/microsoft/knowledge/security/use-secrettext-for-credentials.md @@ -9,8 +9,6 @@ application-area: [all] # Use SecretText for credentials -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - ## Description SecretText is a compile-time-checked AL type for credentials, API keys, tokens, and similar sensitive values. The compiler rejects literal assignments to SecretText and blocks implicit conversion back to Text or Code, which prevents many accidental disclosures via logs, errors, and the debugger (regular and snapshot). A SecretText value remains opaque throughout its lifetime. diff --git a/microsoft/knowledge/security/use-secrettext-with-httpclient.md b/microsoft/knowledge/security/use-secrettext-with-httpclient.md index 2acb268..6a0ed9a 100644 --- a/microsoft/knowledge/security/use-secrettext-with-httpclient.md +++ b/microsoft/knowledge/security/use-secrettext-with-httpclient.md @@ -9,8 +9,6 @@ application-area: [all] # Use SecretText with HttpClient -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. - ## Description HttpRequestMessage, HttpHeaders, and HttpContent expose SecretText overloads so credentials never have to be converted back to Text to be sent. Key APIs: HttpRequestMessage.SetSecretRequestUri (for URIs containing secrets), HttpHeaders.Add(name, SecretText) for authorization headers, HttpHeaders.ContainsSecret to probe secret-valued headers, HttpContent.WriteFrom(SecretText) for request bodies, and HttpContent.ReadAs(SecretText) to pull response bodies into a secret destination. From 9a4198eb28a6f7cbb5b7db326acbe29fe391723a Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 23 Apr 2026 16:00:03 +0200 Subject: [PATCH 06/15] Add [all] sentinel to bc-version; apply to version-agnostic knowledge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most of the corpus — FindSet/SetLoadFields/CalcFields patterns, permission sets, SingleInstance codeunits, DataClassification, IsolatedStorage, transaction scope, SecretText — describes BC platform behaviour that is identical across supported versions. The seed [26..28] range on every file implied a version-specificity the content does not actually have, and there was no way to express "applies to every version" in the schema the way [w1] and [all] already do for countries and application-area. Extend the v1 schema with a universal sentinel for bc-version, parallel to the sentinels already defined for the other dimensions: bc-version: [all] # applies to every BC version [all] is mutually exclusive with explicit versions. Range shorthand ([26..28]) and explicit lists ([26, 27, 28]) continue to work for files genuinely tied to a version-gated API or deprecation. Update read.md (field definition, matching semantics, partial-context rule), write.md (default to [all], use ranges only with a concrete reason), README.md (frontmatter example), and the CI validator. All forty existing knowledge files and the three action skills convert to [all]; none of the current content is version-gated. Validator passes. --- .github/scripts/validate_frontmatter.py | 15 ++++++++++++--- README.md | 2 +- ...owing-globals-in-singleinstance-subscribers.md | 2 +- .../call-setloadfields-before-filters.md | 2 +- ...hoose-maintainsiftindex-by-read-write-ratio.md | 2 +- ...load-common-fields-before-branching-on-case.md | 2 +- ...-only-primary-key-fields-for-reference-work.md | 2 +- .../omit-filter-only-fields-from-setloadfields.md | 2 +- .../order-case-branches-by-frequency.md | 2 +- .../use-deleteall-for-filtered-bulk-deletion.md | 2 +- ...lassify-every-field-with-dataclassification.md | 2 +- .../compose-permission-sets-with-included-sets.md | 2 +- ...not-grant-rights-beyond-a-users-entitlement.md | 2 +- .../guard-bulk-operations-with-istemporary.md | 2 +- ...auth2-over-api-keys-for-external-http-calls.md | 2 +- .../protect-sensitive-data-in-temporary-tables.md | 2 +- .../performance/add-sift-keys-for-flowfields.md | 2 +- .../performance/avoid-calcfields-in-loops.md | 2 +- .../performance/avoid-commit-inside-loops.md | 2 +- .../performance/avoid-findfirst-with-next.md | 2 +- .../avoid-user-interaction-in-transactions.md | 2 +- .../knowledge/performance/filter-before-find.md | 2 +- .../keep-event-subscribers-lightweight.md | 2 +- .../performance/only-fetch-records-you-use.md | 2 +- .../prefer-direct-record-over-recordref.md | 2 +- .../prefer-get-for-primary-key-lookups.md | 2 +- .../set-current-key-to-match-filters.md | 2 +- .../use-addloadfields-in-report-layouts.md | 2 +- .../use-calcsums-for-flowfield-totals.md | 2 +- .../use-findset-readonly-by-default.md | 2 +- .../performance/use-findset-with-next.md | 2 +- .../use-insert-false-when-skipping-triggers.md | 2 +- .../use-isempty-for-existence-checks.md | 2 +- .../use-setloadfields-for-partial-records.md | 2 +- .../use-single-instance-codeunits-for-caching.md | 2 +- .../use-temporary-tables-for-intermediate-data.md | 2 +- .../compose-secrets-with-secretstrsubstno.md | 2 +- ...t-expose-sensitive-data-in-event-publishers.md | 2 +- .../follow-least-privilege-in-permission-sets.md | 2 +- .../security/never-hardcode-secrets-in-al.md | 2 +- ...efer-azure-key-vault-for-production-secrets.md | 2 +- ...se-indirect-permissions-for-elevated-access.md | 2 +- ...nherent-permissions-to-grant-minimal-access.md | 2 +- ...ated-storage-for-module-and-company-secrets.md | 2 +- .../use-nondebuggable-when-parsing-secrets.md | 2 +- .../security/use-secrettext-for-credentials.md | 2 +- .../security/use-secrettext-with-httpclient.md | 2 +- microsoft/skills/al-code-review.md | 2 +- microsoft/skills/al-performance-review.md | 2 +- microsoft/skills/al-security-review.md | 2 +- skills/read.md | 11 ++++++----- skills/write.md | 2 +- 52 files changed, 68 insertions(+), 58 deletions(-) diff --git a/.github/scripts/validate_frontmatter.py b/.github/scripts/validate_frontmatter.py index df579e2..d969cef 100644 --- a/.github/scripts/validate_frontmatter.py +++ b/.github/scripts/validate_frontmatter.py @@ -145,10 +145,19 @@ def is_non_empty_list_of_str(value: Any) -> bool: return isinstance(value, list) and len(value) > 0 and all(isinstance(v, str) and v for v in value) -def expand_bc_version(value: Any) -> tuple[list[int] | None, str | None]: - """Return (expanded-list, error-message). One of the two is None.""" +def expand_bc_version(value: Any) -> tuple[list[int] | str | None, str | None]: + """Return (expanded, error-message). One of the two is None. + + For the universal sentinel ["all"], `expanded` is the string "all". + Otherwise it is the expanded list of version integers. + """ if not isinstance(value, list) or not value: return None, "must be a non-empty list" + # Case 0: universal sentinel + if len(value) == 1 and value[0] == "all": + return "all", None + if "all" in value: + return None, "'all' is mutually exclusive with explicit versions" # Case 1: all integers if all(isinstance(v, int) and not isinstance(v, bool) for v in value): if any(v <= 0 for v in value): @@ -162,7 +171,7 @@ def expand_bc_version(value: Any) -> tuple[list[int] | None, str | None]: if start > end: return None, f"range '{value[0]}' is not ascending" return list(range(start, end + 1)), None - return None, "must be a list of integers or a single-element range shorthand like [26..28]" + return None, "must be [all], a list of integers, or a single-element range shorthand like [26..28]" def headings_in_order(body: str) -> list[tuple[str, int]]: diff --git a/README.md b/README.md index ebed31b..b75a49d 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Every knowledge file is a markdown file with mandatory YAML frontmatter. Files t ```yaml --- -bc-version: [26..28] # BC versions this applies to +bc-version: [all] # or [26..28] for version-gated guidance domain: performance # security | performance | ux | telemetry | ... keywords: [query, filtering, partial] # free-text tags for retrieval technologies: [al] # al | javascript | powershell | ... diff --git a/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md b/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md index d2de14e..ed95723 100644 --- a/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md +++ b/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [singleinstance, subscriber, event, memory, session] technologies: [al] diff --git a/community/knowledge/performance/call-setloadfields-before-filters.md b/community/knowledge/performance/call-setloadfields-before-filters.md index 78b455b..01cc1cc 100644 --- a/community/knowledge/performance/call-setloadfields-before-filters.md +++ b/community/knowledge/performance/call-setloadfields-before-filters.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [setloadfields, placement, filter, setrange, query-plan] technologies: [al] diff --git a/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md b/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md index 787c344..e0bc686 100644 --- a/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md +++ b/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [maintainsiftindex, sift, calcsums, flowfield, write-cost] technologies: [al] diff --git a/community/knowledge/performance/load-common-fields-before-branching-on-case.md b/community/knowledge/performance/load-common-fields-before-branching-on-case.md index 1cc8492..bee6065 100644 --- a/community/knowledge/performance/load-common-fields-before-branching-on-case.md +++ b/community/knowledge/performance/load-common-fields-before-branching-on-case.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [setloadfields, case, conditional, branch, field-loading] technologies: [al] diff --git a/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md b/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md index f13f444..3b4b8f9 100644 --- a/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md +++ b/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [setloadfields, primary-key, reference, existence-check, memory] technologies: [al] diff --git a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md b/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md index 3674836..017b567 100644 --- a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md +++ b/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [setloadfields, filter, field-exclusion, index] technologies: [al] diff --git a/community/knowledge/performance/order-case-branches-by-frequency.md b/community/knowledge/performance/order-case-branches-by-frequency.md index 90518f6..9f78100 100644 --- a/community/knowledge/performance/order-case-branches-by-frequency.md +++ b/community/knowledge/performance/order-case-branches-by-frequency.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [case, branch, frequency, control-flow, hot-path] technologies: [al] diff --git a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md b/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md index 194dae4..101672a 100644 --- a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md +++ b/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [deleteall, bulk-delete, sql, ondelete, trigger-bypass] technologies: [al] diff --git a/community/knowledge/security/classify-every-field-with-dataclassification.md b/community/knowledge/security/classify-every-field-with-dataclassification.md index 79c27b1..bca3219 100644 --- a/community/knowledge/security/classify-every-field-with-dataclassification.md +++ b/community/knowledge/security/classify-every-field-with-dataclassification.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [dataclassification, gdpr, privacy, euii, compliance] technologies: [al] diff --git a/community/knowledge/security/compose-permission-sets-with-included-sets.md b/community/knowledge/security/compose-permission-sets-with-included-sets.md index 3de55d0..b072a67 100644 --- a/community/knowledge/security/compose-permission-sets-with-included-sets.md +++ b/community/knowledge/security/compose-permission-sets-with-included-sets.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [permissionset, includedpermissionsets, assignable, composition, role] technologies: [al] diff --git a/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md b/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md index dfc2666..fe68d58 100644 --- a/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md +++ b/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [entitlement, permissionset, license, clipping, sandbox-drift] technologies: [al] diff --git a/community/knowledge/security/guard-bulk-operations-with-istemporary.md b/community/knowledge/security/guard-bulk-operations-with-istemporary.md index baf2bd8..b3559a6 100644 --- a/community/knowledge/security/guard-bulk-operations-with-istemporary.md +++ b/community/knowledge/security/guard-bulk-operations-with-istemporary.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [istemporary, deleteall, modifyall, safeguard, precondition] technologies: [al] diff --git a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md b/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md index ab30675..7ae5e24 100644 --- a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md +++ b/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [oauth2, api-key, authentication, httpclient, token-refresh] technologies: [al] diff --git a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md b/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md index e6d475d..37ce915 100644 --- a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md +++ b/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [temporary-table, data-protection, permission, cleanup] technologies: [al] diff --git a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md b/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md index b2dce78..1a1843f 100644 --- a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md +++ b/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [sift, sumindexfields, flowfield, key, aa0232] technologies: [al] diff --git a/microsoft/knowledge/performance/avoid-calcfields-in-loops.md b/microsoft/knowledge/performance/avoid-calcfields-in-loops.md index c89509a..4a94f47 100644 --- a/microsoft/knowledge/performance/avoid-calcfields-in-loops.md +++ b/microsoft/knowledge/performance/avoid-calcfields-in-loops.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [calcfields, flowfield, loop, n-plus-one] technologies: [al] diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.md b/microsoft/knowledge/performance/avoid-commit-inside-loops.md index f8e3943..fefd4f3 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.md +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [commit, loop, transaction, lock] technologies: [al] diff --git a/microsoft/knowledge/performance/avoid-findfirst-with-next.md b/microsoft/knowledge/performance/avoid-findfirst-with-next.md index 92fe53f..267aaac 100644 --- a/microsoft/knowledge/performance/avoid-findfirst-with-next.md +++ b/microsoft/knowledge/performance/avoid-findfirst-with-next.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [findfirst, findlast, get, next, aa0233] technologies: [al] diff --git a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md b/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md index a3a9041..bf0895f 100644 --- a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md +++ b/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [confirm, strmenu, message, transaction, dialog] technologies: [al] diff --git a/microsoft/knowledge/performance/filter-before-find.md b/microsoft/knowledge/performance/filter-before-find.md index 271a544..f267761 100644 --- a/microsoft/knowledge/performance/filter-before-find.md +++ b/microsoft/knowledge/performance/filter-before-find.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [filter, setrange, setfilter, findset, scan] technologies: [al] diff --git a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md index 25e2ceb..5079bf2 100644 --- a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md +++ b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [event, subscriber, publisher, extension] technologies: [al] diff --git a/microsoft/knowledge/performance/only-fetch-records-you-use.md b/microsoft/knowledge/performance/only-fetch-records-you-use.md index 8c45801..b0d7a61 100644 --- a/microsoft/knowledge/performance/only-fetch-records-you-use.md +++ b/microsoft/knowledge/performance/only-fetch-records-you-use.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [findset, get, aa0175, wasted-fetch, read] technologies: [al] diff --git a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md index 9916ef8..dce8733 100644 --- a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md +++ b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [recordref, fieldref, dynamic, reflection] technologies: [al] diff --git a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md b/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md index 6102d47..8dfa92b 100644 --- a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md +++ b/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [get, findfirst, primary-key, lookup] technologies: [al] diff --git a/microsoft/knowledge/performance/set-current-key-to-match-filters.md b/microsoft/knowledge/performance/set-current-key-to-match-filters.md index 87b4bf9..d3525aa 100644 --- a/microsoft/knowledge/performance/set-current-key-to-match-filters.md +++ b/microsoft/knowledge/performance/set-current-key-to-match-filters.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [setcurrentkey, key, index, sort, filter] technologies: [al] diff --git a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md b/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md index 4331947..0073b07 100644 --- a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md +++ b/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [report, addloadfields, ondatapreitem, layout, partial-record] technologies: [al] diff --git a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md b/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md index 19a2e2c..f934cae 100644 --- a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md +++ b/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [calcsums, sift, sum, aggregate, totals] technologies: [al] diff --git a/microsoft/knowledge/performance/use-findset-readonly-by-default.md b/microsoft/knowledge/performance/use-findset-readonly-by-default.md index e3d3bba..602dba6 100644 --- a/microsoft/knowledge/performance/use-findset-readonly-by-default.md +++ b/microsoft/knowledge/performance/use-findset-readonly-by-default.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [findset, lock, locktable, readonly, update] technologies: [al] diff --git a/microsoft/knowledge/performance/use-findset-with-next.md b/microsoft/knowledge/performance/use-findset-with-next.md index f15912f..2232a05 100644 --- a/microsoft/knowledge/performance/use-findset-with-next.md +++ b/microsoft/knowledge/performance/use-findset-with-next.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [findset, next, repeat, iteration, aa0181] technologies: [al] diff --git a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md b/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md index 748c6a2..a1391ba 100644 --- a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md +++ b/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [insert, modify, delete, triggers, parameters] technologies: [al] diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-checks.md b/microsoft/knowledge/performance/use-isempty-for-existence-checks.md index 7199a74..58b483a 100644 --- a/microsoft/knowledge/performance/use-isempty-for-existence-checks.md +++ b/microsoft/knowledge/performance/use-isempty-for-existence-checks.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [isempty, count, findfirst, existence] technologies: [al] diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md index bcbf064..3ee35d4 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [setloadfields, partial-record, blob, bandwidth] technologies: [al] diff --git a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md b/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md index ca98596..d8303ff 100644 --- a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md +++ b/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [singleinstance, cache, codeunit, session] technologies: [al] diff --git a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md b/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md index 3c12938..c5ca053 100644 --- a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md +++ b/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: performance keywords: [temporary-table, in-memory, intermediate, working-set] technologies: [al] diff --git a/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.md b/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.md index 0d88c52..3f87594 100644 --- a/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.md +++ b/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [secretstrsubstno, secrettext, composition] technologies: [al] diff --git a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md index b8907a8..7ad3ad3 100644 --- a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md +++ b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [event, publisher, extensibility, var-parameter] technologies: [al] diff --git a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md b/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md index d33c7be..09290f8 100644 --- a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md +++ b/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [permissionset, least-privilege, rimd, tabledata] technologies: [al] diff --git a/microsoft/knowledge/security/never-hardcode-secrets-in-al.md b/microsoft/knowledge/security/never-hardcode-secrets-in-al.md index 4be50aa..0d333c2 100644 --- a/microsoft/knowledge/security/never-hardcode-secrets-in-al.md +++ b/microsoft/knowledge/security/never-hardcode-secrets-in-al.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [secrets, credentials, hardcoded, label, apikey] technologies: [al] diff --git a/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md b/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md index bffe8c5..7a35168 100644 --- a/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md +++ b/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [keyvault, azure, secrets, rotation, audit] technologies: [al] diff --git a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md b/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md index ecf648b..d93efea 100644 --- a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md +++ b/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [indirect-permission, elevation, permissionset] technologies: [al] diff --git a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md b/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md index d305791..d5b33f4 100644 --- a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md +++ b/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [inherentpermissions, attribute, least-privilege] technologies: [al] diff --git a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md index 78b46dc..a5e00f0 100644 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md +++ b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [isolatedstorage, encryption, datascope, secrets] technologies: [al] diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md index 4389394..f2b0d5c 100644 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md +++ b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [nondebuggable, secrettext, attribute, parse] technologies: [al] diff --git a/microsoft/knowledge/security/use-secrettext-for-credentials.md b/microsoft/knowledge/security/use-secrettext-for-credentials.md index 0401d9a..505d695 100644 --- a/microsoft/knowledge/security/use-secrettext-for-credentials.md +++ b/microsoft/knowledge/security/use-secrettext-for-credentials.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [secrettext, credentials, debugger, type] technologies: [al] diff --git a/microsoft/knowledge/security/use-secrettext-with-httpclient.md b/microsoft/knowledge/security/use-secrettext-with-httpclient.md index 6a0ed9a..9b73fec 100644 --- a/microsoft/knowledge/security/use-secrettext-with-httpclient.md +++ b/microsoft/knowledge/security/use-secrettext-with-httpclient.md @@ -1,5 +1,5 @@ --- -bc-version: [26..28] +bc-version: [all] domain: security keywords: [httpclient, secrettext, headers, uri] technologies: [al] diff --git a/microsoft/skills/al-code-review.md b/microsoft/skills/al-code-review.md index 4f27782..d60e78b 100644 --- a/microsoft/skills/al-code-review.md +++ b/microsoft/skills/al-code-review.md @@ -6,7 +6,7 @@ title: AL code review description: Reviews AL source changes by composing the AL review leaf skills (performance, security, ...). inputs: [pr-diff, file-path] outputs: [findings-report] -bc-version: [26..28] +bc-version: [all] technologies: [al] countries: [w1] application-area: [all] diff --git a/microsoft/skills/al-performance-review.md b/microsoft/skills/al-performance-review.md index 4b52d21..5762391 100644 --- a/microsoft/skills/al-performance-review.md +++ b/microsoft/skills/al-performance-review.md @@ -6,7 +6,7 @@ title: AL performance review description: Reviews AL source changes against performance guidance from BCQuality. inputs: [pr-diff, file-path] outputs: [findings-report] -bc-version: [26..28] +bc-version: [all] technologies: [al] countries: [w1] application-area: [all] diff --git a/microsoft/skills/al-security-review.md b/microsoft/skills/al-security-review.md index cecfbd5..1389785 100644 --- a/microsoft/skills/al-security-review.md +++ b/microsoft/skills/al-security-review.md @@ -6,7 +6,7 @@ title: AL security review description: Reviews AL source changes against security guidance from BCQuality. inputs: [pr-diff, file-path] outputs: [findings-report] -bc-version: [26..28] +bc-version: [all] technologies: [al] countries: [w1] application-area: [all] diff --git a/skills/read.md b/skills/read.md index b9a1562..08bce2c 100644 --- a/skills/read.md +++ b/skills/read.md @@ -26,7 +26,7 @@ A file that violates any of these rules is invalid and MUST be skipped by consum ```yaml --- -bc-version: [26, 27, 28] # or the range shorthand [26..28] +bc-version: [all] # or [26, 27, 28] or the range shorthand [26..28] domain: performance keywords: [query, filtering, partial] technologies: [al] @@ -39,12 +39,13 @@ All six fields are required. Missing or empty fields invalidate the file. ### Fields -**`bc-version`** — Array. The Business Central major versions this file applies to. Two forms are accepted: +**`bc-version`** — Array. The Business Central major versions this file applies to. Three forms are accepted: +- Universal sentinel: `[all]` means the guidance applies to every BC version and matches any target. - Explicit list: `[26, 27, 28]`. - Range shorthand: `[26..28]` means every integer from 26 through 28 inclusive. -Consumers MUST expand ranges to the full set before comparison. +`[all]` is mutually exclusive with explicit versions; do not combine. Consumers MUST expand ranges to the full set before comparison. **`domain`** — String. A single domain tag that places the file within a broader area of concern. Standard values include `performance`, `security`, `ux`, `telemetry`, `testing`, `api`, `pipelines`, `finance`, `supply-chain`, `manufacturing`, `jobs`. New domains may be introduced by contributors; no closed enumeration is enforced at the schema level. Consumers MUST treat unknown domains as valid. @@ -93,7 +94,7 @@ Conflict detection is the consumer's responsibility; BCQuality does not enforce When a consumer filters or matches files against a task context, these rules apply: -- **`bc-version`** — the target BC version MUST be an element of the file's expanded `bc-version` set. Range shorthand (`[26..28]`) MUST be expanded before comparison. +- **`bc-version`** — the file matches if its set is `[all]`, or if the target BC version is an element of the file's expanded `bc-version` set. Range shorthand (`[26..28]`) MUST be expanded before comparison. - **`technologies`** — non-empty intersection between the task's technologies and the file's technologies. There is no sentinel for this field. - **`countries`** — the file matches if its set contains `w1`, or if there is a non-empty intersection with the task's countries. - **`application-area`** — the file matches if its set contains `all`, or if there is a non-empty intersection with the task's application areas. @@ -104,7 +105,7 @@ A file is **applicable** to a task when all four rules match. Applicability is a A task context may omit one or more dimensions (for example, a skill invoked against a raw file path with no known target BC version). For any omitted dimension: -- If the file's value for that dimension is a universal sentinel (`w1` for countries, `all` for application-area), the rule matches. +- If the file's value for that dimension is a universal sentinel (`all` for bc-version, `w1` for countries, `all` for application-area), the rule matches. - Otherwise the rule is treated as **unknown**, not as a match and not as a failure. A file with any `unknown` rule is **conditionally applicable**. A consumer MAY include conditionally applicable files in the worklist; if it does, every finding derived from such a file MUST have `confidence` no higher than `medium` and MUST record the unknown dimensions in the finding's `message`. A consumer MAY be configured to exclude conditionally applicable files entirely. diff --git a/skills/write.md b/skills/write.md index 22af773..6fe2eee 100644 --- a/skills/write.md +++ b/skills/write.md @@ -43,7 +43,7 @@ Knowledge files do not contain code. Samples live as **sibling files** next to t ## Choosing frontmatter values -**`bc-version`.** Claim only the versions you have evidence for. If the guidance is known to apply from BC 24 onward and you have tested against 26–28, write `[26..28]`, not `[24..28]`. Under-claim; a future contributor can widen the range. +**`bc-version`.** Default to `[all]` when the guidance is universal — a BC language pattern, a property on a long-standing platform type, a CodeCop rule, or a platform behaviour that has not changed across versions. Use an explicit list or range (`[26, 27, 28]`, `[26..28]`) only when the guidance is tied to a version-gated API, a deprecation, or platform behaviour that genuinely differs across versions. Most knowledge files should be `[all]`; reach for a range only with a concrete reason. **`domain`.** Pick one. If two fit, the file is probably two concerns. If no existing domain fits, introduce a new one — domains are open. Prefer existing domains when they are a reasonable fit, to keep retrieval predictable. From e570d6113fdcce32492c398fbf090b5efe389bef Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 23 Apr 2026 16:43:42 +0200 Subject: [PATCH 07/15] Extract 55 knowledge articles from BC review-agent prompt Adds 55 articles (plus 76 code samples) spanning four new domains and two existing domains, extracted from the internal Business Central review-agent prompt. Content was filtered against BCQuality's remedial-knowledge premise: each article encodes BC-specific behaviour, a CodeCop rule, a platform API semantic, or an anti-false-positive guideline that a capable LLM would otherwise get wrong. New domains: - privacy (11 articles): DataClassification inheritance semantics, the StrSubstNo-defeats-Error-telemetry-classification pitfall, Privacy Notice consent for outgoing requests, anti-false-positives for pages and in-memory data. - upgrade (11 articles): upgrade-codeunit structure, upgrade-tag lifecycle and registration, protected DB reads, DataTransfer for large datasets, InitValue semantics, enum-ordinal preservation, obsolete-workflow, first-install detection. - ui (9 articles): caption capitalization by phrase type, tooltip voice, teaching-tip vs tooltip, tour-tip conventions, character limits, banned terms, ampersand handling, title punctuation. - style (11 articles): label-suffix convention, API page naming, temporary-variable prefix, label properties (Comment/Locked), named invocations, FieldCaption in user messages, OptionCaption pairing, Error-parameter passing, `this` keyword, required parentheses, file naming. Gaps in existing domains: - performance (11 articles): production-scale table catalog (no row counts, per internal-data concern), anti-false-positive for bounded tables, guard-before-Get ordering, redundant-Get-in-OnAfterGetRecord, LockTable in read-only helpers, combined ModifyAll passes, writes in OnAfterGetRecord, SetLoadFields heuristics, temporary-table regressions, FlowField source-table widening, MaintainSQLIndex disabling SIFT. - security (2 articles): environment-specific hardcoded GUIDs, ValidateTableRelation=false on user input. Intentionally excluded: specific production P95 row-count numbers (aggregated internal telemetry); rewritten as categorical guidance on which tables to treat as production-scale without publishing sizes. All articles use `bc-version: [all]` (applies to every BC version, per the new schema sentinel). Validator passes with 0 errors / 0 warnings. --- .../combine-multiple-modifyall-calls.bad.al | 13 ++++++ .../combine-multiple-modifyall-calls.good.al | 16 +++++++ .../combine-multiple-modifyall-calls.md | 26 +++++++++++ ...-not-flag-performance-on-bounded-tables.md | 22 ++++++++++ ...-modify-records-in-onaftergetrecord.bad.al | 15 +++++++ ...modify-records-in-onaftergetrecord.good.al | 35 +++++++++++++++ ...-not-modify-records-in-onaftergetrecord.md | 26 +++++++++++ ...-re-get-rec-inside-onaftergetrecord.bad.al | 34 +++++++++++++++ ...re-get-rec-inside-onaftergetrecord.good.al | 30 +++++++++++++ ...-not-re-get-rec-inside-onaftergetrecord.md | 26 +++++++++++ ...-flowfield-calcformula-to-larger-tables.md | 22 ++++++++++ .../guard-before-get-not-after.bad.al | 15 +++++++ .../guard-before-get-not-after.good.al | 16 +++++++ .../performance/guard-before-get-not-after.md | 26 +++++++++++ ...letemporary-on-api-and-background-pages.md | 22 ++++++++++ .../maintainsqlindex-false-disables-sift.md | 22 ++++++++++ ...fields-on-narrow-tables-and-short-loops.md | 22 ++++++++++ ...-and-write-paths-to-avoid-locktable.bad.al | 14 ++++++ ...and-write-paths-to-avoid-locktable.good.al | 17 ++++++++ ...only-and-write-paths-to-avoid-locktable.md | 26 +++++++++++ ...ledger-entry-tables-as-production-scale.md | 22 ++++++++++ ...lassification-is-a-table-field-property.md | 22 ++++++++++ ...om-isolated-storage-to-plain-fields.bad.al | 13 ++++++ ...m-isolated-storage-to-plain-fields.good.al | 10 +++++ ...i-from-isolated-storage-to-plain-fields.md | 26 +++++++++++ ...-logged-to-telemetry-message-is-not.bad.al | 16 +++++++ ...logged-to-telemetry-message-is-not.good.al | 15 +++++++ ...r-is-logged-to-telemetry-message-is-not.md | 26 +++++++++++ .../flowfields-auto-inherit-systemmetadata.md | 22 ++++++++++ ...in-memory-data-is-not-a-privacy-concern.md | 22 ++++++++++ ...erited-dataclassification-per-field.bad.al | 18 ++++++++ ...rited-dataclassification-per-field.good.al | 23 ++++++++++ ...-inherited-dataclassification-per-field.md | 26 +++++++++++ ...permitted-data-is-not-a-privacy-concern.md | 22 ++++++++++ ...ce-consent-before-outgoing-requests.bad.al | 14 ++++++ ...e-consent-before-outgoing-requests.good.al | 20 +++++++++ ...notice-consent-before-outgoing-requests.md | 26 +++++++++++ ...e-getlasterrortext-before-telemetry.bad.al | 16 +++++++ ...-getlasterrortext-before-telemetry.good.al | 15 +++++++ ...itize-getlasterrortext-before-telemetry.md | 26 +++++++++++ ...ssification-on-every-telemetry-call.bad.al | 16 +++++++ ...sification-on-every-telemetry-call.good.al | 17 ++++++++ ...aclassification-on-every-telemetry-call.md | 26 +++++++++++ ...eaks-error-telemetry-classification.bad.al | 14 ++++++ ...aks-error-telemetry-classification.good.al | 11 +++++ ...d-breaks-error-telemetry-classification.md | 26 +++++++++++ ...validatetablerelation-on-user-input.bad.al | 16 +++++++ ...alidatetablerelation-on-user-input.good.al | 24 +++++++++++ ...ble-validatetablerelation-on-user-input.md | 26 +++++++++++ ...hardcode-environment-specific-guids.bad.al | 14 ++++++ ...ardcode-environment-specific-guids.good.al | 16 +++++++ ...not-hardcode-environment-specific-guids.md | 26 +++++++++++ .../apply-approved-label-suffixes.bad.al | 17 ++++++++ .../apply-approved-label-suffixes.good.al | 20 +++++++++ .../style/apply-approved-label-suffixes.md | 26 +++++++++++ .../style/follow-api-page-naming-rules.bad.al | 22 ++++++++++ .../follow-api-page-naming-rules.good.al | 25 +++++++++++ .../style/follow-api-page-naming-rules.md | 26 +++++++++++ ...comment-on-labels-with-placeholders.bad.al | 15 +++++++ ...omment-on-labels-with-placeholders.good.al | 14 ++++++ ...ude-comment-on-labels-with-placeholders.md | 26 +++++++++++ ...ptioncaption-count-to-optionmembers.bad.al | 22 ++++++++++ ...tioncaption-count-to-optionmembers.good.al | 21 +++++++++ ...ch-optioncaption-count-to-optionmembers.md | 26 +++++++++++ .../name-files-as-object-dot-type-dot-al.md | 22 ++++++++++ ...ers-directly-to-error-no-strsubstno.bad.al | 13 ++++++ ...rs-directly-to-error-no-strsubstno.good.al | 11 +++++ ...ameters-directly-to-error-no-strsubstno.md | 26 +++++++++++ ...emporary-record-variables-with-temp.bad.al | 17 ++++++++ ...mporary-record-variables-with-temp.good.al | 16 +++++++ ...ix-temporary-record-variables-with-temp.md | 26 +++++++++++ ...quire-parentheses-on-function-calls.bad.al | 13 ++++++ ...uire-parentheses-on-function-calls.good.al | 12 ++++++ .../require-parentheses-on-function-calls.md | 26 +++++++++++ ...n-and-tablecaption-in-user-messages.bad.al | 12 ++++++ ...-and-tablecaption-in-user-messages.good.al | 13 ++++++ ...ption-and-tablecaption-in-user-messages.md | 26 +++++++++++ ...d-invocations-instead-of-object-ids.bad.al | 10 +++++ ...-invocations-instead-of-object-ids.good.al | 9 ++++ ...named-invocations-instead-of-object-ids.md | 26 +++++++++++ .../use-this-keyword-in-codeunits.bad.al | 15 +++++++ .../use-this-keyword-in-codeunits.good.al | 21 +++++++++ .../style/use-this-keyword-in-codeunits.md | 26 +++++++++++ ...-are-imperative-and-end-with-period.bad.al | 26 +++++++++++ ...are-imperative-and-end-with-period.good.al | 25 +++++++++++ ...tips-are-imperative-and-end-with-period.md | 26 +++++++++++ .../knowledge/ui/avoid-banned-ui-terms.md | 22 ++++++++++ ...tion-noun-phrase-vs-sentence-phrase.bad.al | 21 +++++++++ ...ion-noun-phrase-vs-sentence-phrase.good.al | 27 ++++++++++++ ...lization-noun-phrase-vs-sentence-phrase.md | 26 +++++++++++ ...-with-specifies-and-end-with-period.bad.al | 26 +++++++++++ ...with-specifies-and-end-with-period.good.al | 25 +++++++++++ ...tart-with-specifies-and-end-with-period.md | 26 +++++++++++ .../ui/respect-ui-text-character-limits.md | 22 ++++++++++ .../ui/titles-have-no-trailing-punctuation.md | 22 ++++++++++ .../tooltips-describe-teaching-tips-guide.md | 22 ++++++++++ .../tour-tips-do-not-use-action-language.md | 22 ++++++++++ ...se-and-not-ampersand-in-ui-captions.bad.al | 20 +++++++++ ...e-and-not-ampersand-in-ui-captions.good.al | 19 ++++++++ .../use-and-not-ampersand-in-ui-captions.md | 26 +++++++++++ ...-onupgrade-triggers-not-inline-code.bad.al | 14 ++++++ ...onupgrade-triggers-not-inline-code.good.al | 31 +++++++++++++ ...from-onupgrade-triggers-not-inline-code.md | 26 +++++++++++ ...-first-install-via-dataversion-zero.bad.al | 15 +++++++ ...first-install-via-dataversion-zero.good.al | 20 +++++++++ ...tect-first-install-via-dataversion-zero.md | 26 +++++++++++ ...ake-external-calls-in-upgrade-codeunits.md | 22 ++++++++++ ...changes-must-be-additive-at-the-end.bad.al | 23 ++++++++++ ...hanges-must-be-additive-at-the-end.good.al | 29 +++++++++++++ ...num-changes-must-be-additive-at-the-end.md | 26 +++++++++++ ...-database-read-in-upgrade-codeunits.bad.al | 19 ++++++++ ...database-read-in-upgrade-codeunits.good.al | 23 ++++++++++ ...very-database-read-in-upgrade-codeunits.md | 26 +++++++++++ ...-does-not-populate-existing-records.bad.al | 14 ++++++ ...does-not-populate-existing-records.good.al | 43 +++++++++++++++++++ ...alue-does-not-populate-existing-records.md | 26 +++++++++++ ...ister-upgrade-tags-with-subscribers.bad.al | 22 ++++++++++ ...ster-upgrade-tags-with-subscribers.good.al | 25 +++++++++++ .../register-upgrade-tags-with-subscribers.md | 26 +++++++++++ ...sential-work-during-upgrade-context.bad.al | 14 ++++++ ...ential-work-during-upgrade-context.good.al | 15 +++++++ ...n-essential-work-during-upgrade-context.md | 26 +++++++++++ ...er-for-large-dataset-initialization.bad.al | 22 ++++++++++ ...r-for-large-dataset-initialization.good.al | 31 +++++++++++++ ...ansfer-for-large-dataset-initialization.md | 26 +++++++++++ ...use-obsolete-pending-before-removed.bad.al | 11 +++++ ...se-obsolete-pending-before-removed.good.al | 13 ++++++ .../use-obsolete-pending-before-removed.md | 26 +++++++++++ ...use-upgrade-tags-not-version-checks.bad.al | 27 ++++++++++++ ...se-upgrade-tags-not-version-checks.good.al | 26 +++++++++++ .../use-upgrade-tags-not-version-checks.md | 26 +++++++++++ 131 files changed, 2799 insertions(+) create mode 100644 microsoft/knowledge/performance/combine-multiple-modifyall-calls.bad.al create mode 100644 microsoft/knowledge/performance/combine-multiple-modifyall-calls.good.al create mode 100644 microsoft/knowledge/performance/combine-multiple-modifyall-calls.md create mode 100644 microsoft/knowledge/performance/do-not-flag-performance-on-bounded-tables.md create mode 100644 microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.bad.al create mode 100644 microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.good.al create mode 100644 microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.md create mode 100644 microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.bad.al create mode 100644 microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.good.al create mode 100644 microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.md create mode 100644 microsoft/knowledge/performance/do-not-retarget-flowfield-calcformula-to-larger-tables.md create mode 100644 microsoft/knowledge/performance/guard-before-get-not-after.bad.al create mode 100644 microsoft/knowledge/performance/guard-before-get-not-after.good.al create mode 100644 microsoft/knowledge/performance/guard-before-get-not-after.md create mode 100644 microsoft/knowledge/performance/keep-sourcetabletemporary-on-api-and-background-pages.md create mode 100644 microsoft/knowledge/performance/maintainsqlindex-false-disables-sift.md create mode 100644 microsoft/knowledge/performance/skip-setloadfields-on-narrow-tables-and-short-loops.md create mode 100644 microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.bad.al create mode 100644 microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al create mode 100644 microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md create mode 100644 microsoft/knowledge/performance/treat-ledger-entry-tables-as-production-scale.md create mode 100644 microsoft/knowledge/privacy/dataclassification-is-a-table-field-property.md create mode 100644 microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al create mode 100644 microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.good.al create mode 100644 microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.md create mode 100644 microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.bad.al create mode 100644 microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.good.al create mode 100644 microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md create mode 100644 microsoft/knowledge/privacy/flowfields-auto-inherit-systemmetadata.md create mode 100644 microsoft/knowledge/privacy/in-memory-data-is-not-a-privacy-concern.md create mode 100644 microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.bad.al create mode 100644 microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.good.al create mode 100644 microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.md create mode 100644 microsoft/knowledge/privacy/pages-displaying-permitted-data-is-not-a-privacy-concern.md create mode 100644 microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.bad.al create mode 100644 microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.good.al create mode 100644 microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.md create mode 100644 microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.bad.al create mode 100644 microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.good.al create mode 100644 microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.md create mode 100644 microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.bad.al create mode 100644 microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.good.al create mode 100644 microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md create mode 100644 microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.bad.al create mode 100644 microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.good.al create mode 100644 microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md create mode 100644 microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.bad.al create mode 100644 microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.good.al create mode 100644 microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.md create mode 100644 microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.bad.al create mode 100644 microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.good.al create mode 100644 microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.md create mode 100644 microsoft/knowledge/style/apply-approved-label-suffixes.bad.al create mode 100644 microsoft/knowledge/style/apply-approved-label-suffixes.good.al create mode 100644 microsoft/knowledge/style/apply-approved-label-suffixes.md create mode 100644 microsoft/knowledge/style/follow-api-page-naming-rules.bad.al create mode 100644 microsoft/knowledge/style/follow-api-page-naming-rules.good.al create mode 100644 microsoft/knowledge/style/follow-api-page-naming-rules.md create mode 100644 microsoft/knowledge/style/include-comment-on-labels-with-placeholders.bad.al create mode 100644 microsoft/knowledge/style/include-comment-on-labels-with-placeholders.good.al create mode 100644 microsoft/knowledge/style/include-comment-on-labels-with-placeholders.md create mode 100644 microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.bad.al create mode 100644 microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.good.al create mode 100644 microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.md create mode 100644 microsoft/knowledge/style/name-files-as-object-dot-type-dot-al.md create mode 100644 microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.bad.al create mode 100644 microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.good.al create mode 100644 microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.md create mode 100644 microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.bad.al create mode 100644 microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.good.al create mode 100644 microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.md create mode 100644 microsoft/knowledge/style/require-parentheses-on-function-calls.bad.al create mode 100644 microsoft/knowledge/style/require-parentheses-on-function-calls.good.al create mode 100644 microsoft/knowledge/style/require-parentheses-on-function-calls.md create mode 100644 microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.bad.al create mode 100644 microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.good.al create mode 100644 microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.md create mode 100644 microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.bad.al create mode 100644 microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.good.al create mode 100644 microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.md create mode 100644 microsoft/knowledge/style/use-this-keyword-in-codeunits.bad.al create mode 100644 microsoft/knowledge/style/use-this-keyword-in-codeunits.good.al create mode 100644 microsoft/knowledge/style/use-this-keyword-in-codeunits.md create mode 100644 microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.bad.al create mode 100644 microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.good.al create mode 100644 microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.md create mode 100644 microsoft/knowledge/ui/avoid-banned-ui-terms.md create mode 100644 microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.bad.al create mode 100644 microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.good.al create mode 100644 microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md create mode 100644 microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.bad.al create mode 100644 microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.good.al create mode 100644 microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md create mode 100644 microsoft/knowledge/ui/respect-ui-text-character-limits.md create mode 100644 microsoft/knowledge/ui/titles-have-no-trailing-punctuation.md create mode 100644 microsoft/knowledge/ui/tooltips-describe-teaching-tips-guide.md create mode 100644 microsoft/knowledge/ui/tour-tips-do-not-use-action-language.md create mode 100644 microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.bad.al create mode 100644 microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.good.al create mode 100644 microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.md create mode 100644 microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.bad.al create mode 100644 microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.good.al create mode 100644 microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.md create mode 100644 microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.bad.al create mode 100644 microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.good.al create mode 100644 microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.md create mode 100644 microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md create mode 100644 microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.bad.al create mode 100644 microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.good.al create mode 100644 microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md create mode 100644 microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.bad.al create mode 100644 microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.good.al create mode 100644 microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.md create mode 100644 microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.bad.al create mode 100644 microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.good.al create mode 100644 microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md create mode 100644 microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al create mode 100644 microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al create mode 100644 microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md create mode 100644 microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.bad.al create mode 100644 microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.good.al create mode 100644 microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.md create mode 100644 microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.bad.al create mode 100644 microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.good.al create mode 100644 microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md create mode 100644 microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.bad.al create mode 100644 microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.good.al create mode 100644 microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.md create mode 100644 microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al create mode 100644 microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al create mode 100644 microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md diff --git a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.bad.al b/microsoft/knowledge/performance/combine-multiple-modifyall-calls.bad.al new file mode 100644 index 0000000..3a9e190 --- /dev/null +++ b/microsoft/knowledge/performance/combine-multiple-modifyall-calls.bad.al @@ -0,0 +1,13 @@ +codeunit 51207 "Perf Sample CombineMA Bad" +{ + procedure UpdateTolerance(DocumentNo: Code[20]; ToleranceAmount: Decimal) + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetRange("Document No.", DocumentNo); + CustLedgerEntry.SetRange(Open, true); + // Two scans over the same filtered rows on a 10M-row ledger table. + CustLedgerEntry.ModifyAll("Accepted Payment Tolerance", ToleranceAmount); + CustLedgerEntry.ModifyAll("Accepted Pmt. Disc. Tolerance", false); + end; +} diff --git a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.good.al b/microsoft/knowledge/performance/combine-multiple-modifyall-calls.good.al new file mode 100644 index 0000000..e1ecaad --- /dev/null +++ b/microsoft/knowledge/performance/combine-multiple-modifyall-calls.good.al @@ -0,0 +1,16 @@ +codeunit 51206 "Perf Sample CombineMA Good" +{ + procedure UpdateTolerance(DocumentNo: Code[20]; ToleranceAmount: Decimal) + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetRange("Document No.", DocumentNo); + CustLedgerEntry.SetRange(Open, true); + if CustLedgerEntry.FindSet(true) then + repeat + CustLedgerEntry."Accepted Payment Tolerance" := ToleranceAmount; + CustLedgerEntry."Accepted Pmt. Disc. Tolerance" := false; + CustLedgerEntry.Modify(false); + until CustLedgerEntry.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.md b/microsoft/knowledge/performance/combine-multiple-modifyall-calls.md new file mode 100644 index 0000000..21c32ff --- /dev/null +++ b/microsoft/knowledge/performance/combine-multiple-modifyall-calls.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [modifyall, bulk-update, filter, scan, recordset] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Combine multiple ModifyAll calls on the same recordset into a single pass + +## Description + +`ModifyAll(Field, Value)` issues a SQL UPDATE against every row matching the record variable's current filters, setting one field. Calling it twice on the same filtered recordset — once per field to update — produces two separate UPDATE statements, each of which has to re-locate the matching rows through the index. On a ledger-entry-scale table with ten million rows and a filter that matches a thousand, the overhead is not a doubling of the update cost but a doubling of the more expensive row-location cost. A single `FindSet(true)` + set-by-set assignment + `Modify(false)` completes both field changes in one pass. + +## Best Practice + +When more than one field needs to change on the same filtered recordset, iterate once with `FindSet(true)` and assign all fields per row. Reserve ModifyAll for the case where a single field change covers the whole update. If the filter set is truly huge and the trigger behaviour differs between fields, consider splitting with concrete evidence — otherwise the single-pass loop wins. + +See sample: `combine-multiple-modifyall-calls.good.al`. + +## Anti Pattern + +Applying `SetRange` against `CustLedgerEntry` on `"Document No."` and then calling `ModifyAll("Accepted Payment Tolerance", ...)` followed by `ModifyAll("Accepted Pmt. Disc. Tolerance", false)` — two scans over the same filtered rows. On Cust. Ledger Entry with production-scale data the redundant second scan is the dominant cost. + +See sample: `combine-multiple-modifyall-calls.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-flag-performance-on-bounded-tables.md b/microsoft/knowledge/performance/do-not-flag-performance-on-bounded-tables.md new file mode 100644 index 0000000..216e727 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-flag-performance-on-bounded-tables.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [setup-table, temporary, bounded-table, metadata, migration, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not flag performance on inherently bounded tables + +## Description + +Several categories of Business Central tables are so small, so rarely accessed, or so in-memory that performance heuristics that make sense on Item Ledger Entry produce noise when applied to them. Temporary records (`TableType = Temporary`, `SourceTableTemporary = true`) live in memory and any access pattern is fast. Singleton setup tables (`Sales & Receivables Setup`, `General Ledger Setup`, `*Setup` tables generally) hold one row per company. Small bounded tables — enum mappings, permission objects, Role IDs — count in the dozens. System metadata tables (`TableMetadata`, `Field`, `AllObjWithCaption`) are bounded by the object catalog. Admin, Migration, Setup, Wizard, and Hybrid* pages are used infrequently with small datasets. + +## Best Practice + +Skip performance findings on these categories unless the code is specifically pathological (unbounded loop that multiplies cost non-linearly). A missing SetLoadFields on a singleton Setup table is not a finding. A Count on a 30-row permission mapping is not a finding. An admin page that iterates a bounded list once per invocation is not a finding. Reserving reviewer attention for the tables where it matters is half the value of the heuristics — noise on bounded tables trains authors to ignore the signal. + +## Anti Pattern + +Flagging `SalesReceivablesSetup.Get()` followed by `SetLoadFields()` on a handful of fields as "missing partial record optimization". Flagging a `FindSet` + loop on `Role ID` mapping because the loop has no SetCurrentKey. Flagging a Migration codeunit for writing many records, when the entire migration runs once per customer. All three burn author attention on cases that are not regressions. diff --git a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.bad.al b/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.bad.al new file mode 100644 index 0000000..fbf5047 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.bad.al @@ -0,0 +1,15 @@ +pageextension 51209 "Perf Sample NoModifyOAGR Bad" extends "Customer List" +{ + trigger OnAfterGetRecord() + begin + // Every scroll writes to the database. Every OnModify subscriber on + // Customer fires alongside. Write volume scales with mouse-wheel speed. + Rec."Last Warning Flag" := CalcWarning(); + Rec.Modify(); + end; + + local procedure CalcWarning(): Boolean + begin + exit(false); + end; +} diff --git a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.good.al b/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.good.al new file mode 100644 index 0000000..8e4d641 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.good.al @@ -0,0 +1,35 @@ +page 51208 "Perf Sample NoModifyOAGR Good" +{ + PageType = List; + SourceTable = Customer; + + layout + { + area(Content) + { + repeater(Group) + { + field("No."; Rec."No.") { ApplicationArea = All; } + field(WarningFlag; ShowWarning) + { + ApplicationArea = All; + Caption = 'Warning'; + } + } + } + } + + trigger OnAfterGetRecord() + begin + // Page-local variable. No database write per row. + ShowWarning := CalcWarning(Rec); + end; + + var + ShowWarning: Boolean; + + local procedure CalcWarning(var Customer: Record Customer): Boolean + begin + exit(false); + end; +} diff --git a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.md b/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.md new file mode 100644 index 0000000..8133229 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [onaftergetrecord, modify, page, trigger, write-per-scroll] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not Modify records inside OnAfterGetRecord + +## Description + +`OnAfterGetRecord` fires for every row the page or repeater renders. On a list page the user scrolls through, the trigger runs hundreds of times per second. A `Modify()` call inside the trigger writes to the database for every row scrolled past — the user's mouse wheel generates the write storm, and the effect compounds with every other subscriber that reacts to the OnModify event. The database activity is usually invisible to the author in development, because the list page loads ten rows; on a production tenant scrolling through thousands of rows, the page becomes the top source of write volume. + +## Best Practice + +Derive display-only state into a page-level variable and bind that variable to the field control instead of writing to `Rec`. If the computed value is genuinely a stored attribute of the record, compute it once at the authoring site (OnValidate, OnInsert) and display the stored value on the list — do not recompute and rewrite on every render. + +See sample: `do-not-modify-records-in-onaftergetrecord.good.al`. + +## Anti Pattern + +An OnAfterGetRecord body that assigns a computed value to `Rec."Warning Flag"` and calls `Rec.Modify()` so the flag persists. The write fires per scroll, per user, per second — and every subscriber on the Rec's OnModify fires alongside. + +See sample: `do-not-modify-records-in-onaftergetrecord.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.bad.al b/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.bad.al new file mode 100644 index 0000000..0a5469a --- /dev/null +++ b/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.bad.al @@ -0,0 +1,34 @@ +page 51203 "Perf Sample ReGetRec Bad" +{ + PageType = List; + SourceTable = "Assembly Line"; + + layout + { + area(Content) + { + repeater(Group) + { + field("No."; Rec."No.") { ApplicationArea = All; } + } + } + } + + trigger OnAfterGetRecord() + var + AssemblyLineRec: Record "Assembly Line"; + begin + // Redundant Get. The page runtime already loaded this row into Rec. + // At list-page scale this fires hundreds of times per scroll. + AssemblyLineRec.Get(Rec."Document Type", Rec."Document No.", Rec."Line No."); + ShowWarning := CheckAvailability(AssemblyLineRec); + end; + + var + ShowWarning: Boolean; + + local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean + begin + exit(false); + end; +} diff --git a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.good.al b/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.good.al new file mode 100644 index 0000000..2594f54 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.good.al @@ -0,0 +1,30 @@ +page 51202 "Perf Sample ReGetRec Good" +{ + PageType = List; + SourceTable = "Assembly Line"; + + layout + { + area(Content) + { + repeater(Group) + { + field("No."; Rec."No.") { ApplicationArea = All; } + } + } + } + + trigger OnAfterGetRecord() + begin + // Rec already holds the current row's values; no Get needed. + ShowWarning := CheckAvailability(Rec); + end; + + var + ShowWarning: Boolean; + + local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean + begin + exit(false); + end; +} diff --git a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.md b/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.md new file mode 100644 index 0000000..120b079 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [onaftergetrecord, get, rec, page-runtime, redundant-fetch] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not re-Get the current record inside OnAfterGetRecord + +## Description + +The page runtime loads the current record before firing `OnAfterGetRecord` — `Rec` already holds the row's values when the trigger body runs. Calling `Rec.Get(...)` (or any equivalent Get against the same key) inside the trigger issues a second database round-trip for data the runtime just fetched. On a list page that displays hundreds of rows during a scroll, this turns into hundreds of wasted round-trips per user interaction. The same concern applies to `OnAfterGetCurrRecord` on card and document pages, though the impact is smaller because the trigger fires per selection rather than per row. + +## Best Practice + +Read from `Rec` directly. When a helper method needs a different record, pass `Rec` as an argument or let the helper fetch its own lookup once; do not re-Get the current row. If the code truly needs a fresh value because it was modified by another session, design the refresh explicitly — document it in a comment — rather than paying the cost on every trigger fire. + +See sample: `do-not-re-get-rec-inside-onaftergetrecord.good.al`. + +## Anti Pattern + +An `OnAfterGetRecord` trigger body that starts with `AssemblyLineRec.Get("Document Type", "Document No.", "Line No.")` for the same keys the page runtime has already used — the Get restates what `Rec` already holds. Replace with a direct call against `Rec` (`CheckAvailability(Rec)`). + +See sample: `do-not-re-get-rec-inside-onaftergetrecord.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-retarget-flowfield-calcformula-to-larger-tables.md b/microsoft/knowledge/performance/do-not-retarget-flowfield-calcformula-to-larger-tables.md new file mode 100644 index 0000000..6d1a572 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-retarget-flowfield-calcformula-to-larger-tables.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [flowfield, calcformula, regression, source-table, sift] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not retarget a FlowField's CalcFormula to a larger source table + +## Description + +A FlowField's CalcFormula is evaluated every time the field is read — every time the page renders, every CalcFields call, every list page filter that references the field. Changing the CalcFormula's source table from a smaller, bounded, or already-filtered table to a larger unfiltered one multiplies the per-read cost. A common shape is the refactor from "Posted X" to "X" — the unposted line table is typically an order of magnitude larger and carries rows that the original FlowField never considered. The change compiles and may look like a simple scope widening; the performance impact is not visible until production load. + +## Best Practice + +When a FlowField CalcFormula changes source table, evaluate the before/after row counts, ensure a SIFT key exists on the new source that matches the formula's filters (see `add-sift-keys-for-flowfields`), and verify no existing callers rely on the tighter scope. If the widening is intentional, the corresponding SIFT keys on the new source must ship in the same PR. + +## Anti Pattern + +Changing a `sum("Posted Expense Report Line"."Amount" where(...))` formula to `sum("Expense Report Line"."Amount" where(...))` without touching the source table's keys. Every list page and dashboard that reads the FlowField now aggregates over the unposted table too, almost always without a supporting SIFT key. diff --git a/microsoft/knowledge/performance/guard-before-get-not-after.bad.al b/microsoft/knowledge/performance/guard-before-get-not-after.bad.al new file mode 100644 index 0000000..da0d391 --- /dev/null +++ b/microsoft/knowledge/performance/guard-before-get-not-after.bad.al @@ -0,0 +1,15 @@ +codeunit 51201 "Perf Sample GuardBeforeGet Bad" +{ + procedure HandleLine(var PurchaseLine: Record "Purchase Line") + var + PurchaseHeader: Record "Purchase Header"; + begin + // Get fires on every call — including the ones that exit immediately below. + PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); + + if PurchaseLine."Selected Alloc. Account No." = '' then + exit; + + // Work with PurchaseHeader. + end; +} diff --git a/microsoft/knowledge/performance/guard-before-get-not-after.good.al b/microsoft/knowledge/performance/guard-before-get-not-after.good.al new file mode 100644 index 0000000..5387f0f --- /dev/null +++ b/microsoft/knowledge/performance/guard-before-get-not-after.good.al @@ -0,0 +1,16 @@ +codeunit 51200 "Perf Sample GuardBeforeGet Good" +{ + procedure HandleLine(var PurchaseLine: Record "Purchase Line") + var + PurchaseHeader: Record "Purchase Header"; + begin + // Cheap in-memory check first. Get only when the subsequent code needs the header. + if PurchaseLine."Selected Alloc. Account No." = '' then + exit; + + if not PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No.") then + exit; + + // Work with PurchaseHeader. + end; +} diff --git a/microsoft/knowledge/performance/guard-before-get-not-after.md b/microsoft/knowledge/performance/guard-before-get-not-after.md new file mode 100644 index 0000000..ea93e9d --- /dev/null +++ b/microsoft/knowledge/performance/guard-before-get-not-after.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [get, guard, early-exit, wasted-fetch, conditional] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Place guard conditions before Get, not after + +## Description + +A `Record.Get(Key)` is a database round-trip. When the call site also contains an early-exit condition that may fire before the fetched record is used, the order of the two matters: `Get` first followed by a guard that may exit means every call pays the round-trip, including the calls that immediately return. Flipping the order — evaluate the guard first, `Get` only when needed — costs nothing in the happy path and turns the wasted round-trip into zero work on the exit path. The savings compound on hot tables and on code paths entered many times per user action. + +## Best Practice + +Evaluate cheap, in-memory conditions first. Only issue the `Get` (or `FindFirst`, `FindLast`) when the subsequent code actually needs the record's values. For complex procedures with multiple exit conditions, sort them cheapest-first: in-memory checks, then single-record lookups, then set iteration. + +See sample: `guard-before-get-not-after.good.al`. + +## Anti Pattern + +`PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); if PurchaseLine."Selected Alloc. Account No." = '' then exit;` — the Get fires on every call; the exit discards the result for every call where `Selected Alloc. Account No.` is blank. + +See sample: `guard-before-get-not-after.bad.al`. diff --git a/microsoft/knowledge/performance/keep-sourcetabletemporary-on-api-and-background-pages.md b/microsoft/knowledge/performance/keep-sourcetabletemporary-on-api-and-background-pages.md new file mode 100644 index 0000000..9702960 --- /dev/null +++ b/microsoft/knowledge/performance/keep-sourcetabletemporary-on-api-and-background-pages.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [sourcetabletemporary, tabletype, temporary, api-page, persistence, regression] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not remove SourceTableTemporary or TableType = Temporary without understanding the impact + +## Description + +`SourceTableTemporary = true` on a page, and `TableType = Temporary` on a table, mean the underlying record operates in memory — Insert/Modify/Delete mutate the session buffer, not the database. Removing either property converts the same operations to real SQL writes. On an API page that external callers hit at high frequency, on a background task that processes thousands of records, or on a UI page that composes an in-memory list for display, the change from temporary to persistent can turn a lightweight operation into a major source of database load. The refactor is easy to propose ("why is this temporary?") and expensive to regret. + +## Best Practice + +When a diff removes `SourceTableTemporary = true` or `TableType = Temporary`, require justification explaining why persistence is now required and what paths still write. Review the callers for unexpected new writes, transaction scope, trigger fires, and contention. Keep the property unless the change genuinely needs persistence; an unused-looking temporary table on a bounded page is usually there for a reason. + +## Anti Pattern + +A cleanup PR that deletes `SourceTableTemporary = true` from an API page "because the source table already exists". The API now writes to the real table on every call, every consumer's requests reach the database, and the incidental side-effects in the source table's triggers start firing across tenants. diff --git a/microsoft/knowledge/performance/maintainsqlindex-false-disables-sift.md b/microsoft/knowledge/performance/maintainsqlindex-false-disables-sift.md new file mode 100644 index 0000000..acd3eae --- /dev/null +++ b/microsoft/knowledge/performance/maintainsqlindex-false-disables-sift.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [maintainsqlindex, sift, sumindexfields, flowfield, calcsums, key] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# MaintainSQLIndex = false on a key disables SIFT for the FlowFields that depend on it + +## Description + +SIFT relies on the underlying SQL index being maintained by the platform. Setting `MaintainSQLIndex = false` on a key drops the SQL index without dropping the AL key declaration — the key compiles, FlowFields that reference its SumIndexFields compile, and CalcSums calls against matching filters compile. At runtime, however, the SIFT optimization silently cannot engage, and every aggregate falls back to a table scan. The symptom is a FlowField whose read time degrades linearly with row count, with no code-level signal pointing at the key property as the cause. + +## Best Practice + +Keep `MaintainSQLIndex = true` (the default) on any key whose SumIndexFields back a FlowField or that callers use with CalcSums. When a key is genuinely unused and the SQL index cost is the concern, remove the key entirely rather than leaving it in place with `MaintainSQLIndex = false`. If the FlowField is still needed, pick a different key that is maintained. + +## Anti Pattern + +A source-table key declared with `SumIndexFields` and `MaintainSQLIndex = false`, with a FlowField referencing those sum fields. The FlowField appears to work in development against small datasets and becomes a full table scan on production-scale data, with no error message and no obvious culprit in the code under review. diff --git a/microsoft/knowledge/performance/skip-setloadfields-on-narrow-tables-and-short-loops.md b/microsoft/knowledge/performance/skip-setloadfields-on-narrow-tables-and-short-loops.md new file mode 100644 index 0000000..33a299e --- /dev/null +++ b/microsoft/knowledge/performance/skip-setloadfields-on-narrow-tables-and-short-loops.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [setloadfields, heuristics, narrow-table, short-loop, diminishing-returns] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# SetLoadFields pays off at scale; skip it on narrow tables and short loops + +## Description + +`SetLoadFields` reduces the number of columns the platform hydrates per record. It delivers real savings on wide tables with blob, media, or many text fields when the iteration touches a small subset. Below certain thresholds the accounting flips the other way: narrow tables (fewer than ~10 fields) save almost nothing per row, and short loops (fewer than ~10 iterations) amortize the narrowing over too few fetches to outweigh the extra code and the specification-and-access-set coupling that future edits have to maintain. Recommending SetLoadFields on every Find/Get call produces low-value churn and invites the opposite mistake — listing a field in SetLoadFields and then forgetting to access it, which triggers a second round-trip to load the missing field. + +## Best Practice + +Reach for SetLoadFields when the table is wide (10+ fields, especially with blobs) AND the code path reads a small subset AND the iteration or fetch count is material. When in doubt on a short loop over a narrow table, leave SetLoadFields out; the complexity cost is not earned. The filter-only-field rule from `omit-filter-only-fields-from-setloadfields` still applies: fields used only in filters stay out of the list. + +## Anti Pattern + +A 5-row loop over a 6-field setup table prefaced by `Rec.SetLoadFields(...)`. The author has added two lines of code, coupled the loop to a field specification that needs to be updated on every schema change, and saved nanoseconds. The same pattern applied mechanically to every Find call in a codebase produces hundreds of diffs that do not move the performance needle. diff --git a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.bad.al b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.bad.al new file mode 100644 index 0000000..cb38dbf --- /dev/null +++ b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.bad.al @@ -0,0 +1,14 @@ +codeunit 51205 "Perf Sample LockTable Bad" +{ + procedure GetOrCreate(var AgentStatus: Record "Integer"): Boolean + begin + // Every caller takes an exclusive lock, even the ones that only read. + // Under load the helper becomes the dominant contention point. + AgentStatus.LockTable(); + if not AgentStatus.Get(1) then begin + AgentStatus.Number := 1; + AgentStatus.Insert(); + end; + exit(true); + end; +} diff --git a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al new file mode 100644 index 0000000..2dba7e3 --- /dev/null +++ b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al @@ -0,0 +1,17 @@ +codeunit 51204 "Perf Sample LockTable Good" +{ + procedure GetOrCreate(var AgentStatus: Record "Integer"): Boolean + begin + // Read path: no lock. + if AgentStatus.Get(1) then + exit(true); + + // Write path: lock only when we are about to insert. + AgentStatus.LockTable(); + if not AgentStatus.Get(1) then begin + AgentStatus.Number := 1; + AgentStatus.Insert(); + end; + exit(true); + end; +} diff --git a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md new file mode 100644 index 0000000..6158f84 --- /dev/null +++ b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [locktable, read-only, write-path, contention, helper] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Split read-only and write paths so LockTable runs only when needed + +## Description + +LockTable takes an exclusive write lock on the affected table for the remainder of the transaction. In a helper that is called from many read-only sites and a few write sites, placing LockTable unconditionally at the top serializes every reader on every other reader's lock — the helper becomes a system-wide contention point. The correct shape is a conditional structure: try the read-only path first, and only fall through to LockTable when the code genuinely needs to modify the table. + +## Best Practice + +Factor the helper so readers return immediately without a lock and only writers reach the LockTable call. A common pattern: attempt `Rec.Get()` first; if it returns the row, exit with the value; otherwise LockTable and proceed with the Insert. Document the pattern in a comment on the helper so callers understand why the LockTable is inside a branch. + +See sample: `split-read-only-and-write-paths-to-avoid-locktable.good.al`. + +## Anti Pattern + +A `GetOrCreate` helper that unconditionally calls `Rec.LockTable()` at the top, then Gets the row, then returns it. Every reader now blocks every other reader even though none of them intend to write. Under load the helper becomes the dominant bottleneck. + +See sample: `split-read-only-and-write-paths-to-avoid-locktable.bad.al`. diff --git a/microsoft/knowledge/performance/treat-ledger-entry-tables-as-production-scale.md b/microsoft/knowledge/performance/treat-ledger-entry-tables-as-production-scale.md new file mode 100644 index 0000000..cd38037 --- /dev/null +++ b/microsoft/knowledge/performance/treat-ledger-entry-tables-as-production-scale.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [ledger-entry, production-scale, hot-table, item-ledger, gl-entry, sales-invoice-line] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Treat ledger-entry and line-type tables as production-scale when reviewing performance + +## Description + +A handful of Business Central tables grow to millions of rows in production tenants: Item Ledger Entry, Value Entry, G/L Entry, VAT Entry, Customer Ledger Entry, Vendor Ledger Entry, Sales Invoice Line, Purchase Invoice Line, Detailed Cust. Ledg. Entry, Detailed Vendor Ledg. Entry, and equivalent line-type tables. Master-data tables like Customer, Vendor, and Item typically reach the high hundreds of thousands. A performance review that treats these tables with the same latitude as setup tables or small reference lists under-reports real regressions; the same filter-or-key mistake that is invisible on a 50-row table is a full table scan over millions of rows on these. + +## Best Practice + +When a code change touches any of the above tables, demand concrete performance reasoning before accepting it: an appropriate key selection, a SetLoadFields narrowing, filters that use the key prefix, no N+1 inside the iteration. A finding on one of these tables should almost never be downgraded from High to Low on the grounds that "the operation looks small" — at production scale the operation is never small. + +## Anti Pattern + +Applying review heuristics uniformly to all tables. A missing SetCurrentKey on a Setup table changes nothing; the same mistake on Item Ledger Entry turns a list page into a multi-second load. The asymmetry is the whole point of the catalog — knowing which tables warrant the stricter read. diff --git a/microsoft/knowledge/privacy/dataclassification-is-a-table-field-property.md b/microsoft/knowledge/privacy/dataclassification-is-a-table-field-property.md new file mode 100644 index 0000000..c47955f --- /dev/null +++ b/microsoft/knowledge/privacy/dataclassification-is-a-table-field-property.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [dataclassification, table-field, page, api-page, scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# DataClassification is a table-field property, not a page property + +## Description + +DataClassification governs how the platform handles a field's data in telemetry, data-subject requests, and retention tooling. It is declared on the table field, not on the page that displays the field. Pages — card pages, list pages, API pages — simply render fields sourced from a table. A privacy issue with classification is always an issue on the table definition; the page is a display surface. + +## Best Practice + +Flag missing or wrong DataClassification on the table field where the data lives. When a field is exposed through an API page or any other page type, the source table's classification governs. Do not report the same issue on every page that happens to include the field. + +## Anti Pattern + +Reporting a privacy finding on `page 50100 "Customer API"` because it exposes an email field, rather than on `table Customer`'s email field. Fix at the source; the page is not the offender and the same correction applied per-page produces churn without changing the data-classification story. diff --git a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al b/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al new file mode 100644 index 0000000..718983a --- /dev/null +++ b/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al @@ -0,0 +1,13 @@ +tableextension 50911 "Privacy Sample IS Bad" extends "Sales & Receivables Setup" +{ + fields + { + // Refactor moves the delta URL out of encrypted IsolatedStorage into a + // plain table field. Value is now plaintext in SQL, unscoped, indistinguishable + // from non-sensitive content. + field(50100; "Delta Url"; Text[250]) + { + DataClassification = EndUserPseudonymousIdentifiers; + } + } +} diff --git a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.good.al b/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.good.al new file mode 100644 index 0000000..b54ce3a --- /dev/null +++ b/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.good.al @@ -0,0 +1,10 @@ +codeunit 50910 "Privacy Sample IS Good" +{ + procedure StoreDeltaUrl(DeltaUrl: Text) + var + DeltaKeyTok: Label 'SyncDeltaUrl', Locked = true; + begin + // Sensitive delta URL remains encrypted and scoped to the extension. + IsolatedStorage.SetEncrypted(DeltaKeyTok, DeltaUrl, DataScope::Company); + end; +} diff --git a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.md b/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.md new file mode 100644 index 0000000..4e64bff --- /dev/null +++ b/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [isolatedstorage, encryption, tokens, refactor, regression] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not move PII or secrets from IsolatedStorage to plain table fields + +## Description + +IsolatedStorage with SetEncrypted keeps sensitive values — tokens, URLs carrying identifiers, delta cursors with embedded user context — encrypted at rest and scoped to the extension. Moving the same value to a normal table field is a refactor that looks structural but is a privacy and security regression: the value is now plaintext in SQL, visible to every reader of that table, backed up and replicated as ordinary business data. Reviews of existing integrations frequently see this change justified as "easier to query" — the concern is the storage model, not the ergonomics. + +## Best Practice + +Keep tokens, secrets, personal-context URLs, and similar sensitive values in IsolatedStorage (SetEncrypted) or Azure Key Vault. When a refactor moves the value, require an explicit justification and a mitigating control (restricted-read permission set, value-level encryption, redaction in the access path). Otherwise leave it where it was. + +See sample: `do-not-move-pii-from-isolated-storage-to-plain-fields.good.al`. + +## Anti Pattern + +A diff that deletes an `IsolatedStorage.SetEncrypted` call and writes the same value into a new `Text` column on a business table. The value is now unencrypted, unscoped, and indistinguishable from non-sensitive content to any caller reading the table. + +See sample: `do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al`. diff --git a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.bad.al b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.bad.al new file mode 100644 index 0000000..f06f679 --- /dev/null +++ b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.bad.al @@ -0,0 +1,16 @@ +codeunit 50903 "Privacy Sample ErrorVsMsg Bad" +{ + procedure ConfirmThenFail(var Customer: Record Customer) + var + ConfirmQst: Label 'Send welcome email to %1 at %2?', Comment = '%1 = name, %2 = email'; + FailureWithPiiErr: Text; + begin + if not Confirm(ConfirmQst, false, Customer.Name, Customer."E-Mail") then + exit; + + // Pre-built Text with PII, passed to Error: customer name and email reach telemetry. + FailureWithPiiErr := StrSubstNo( + 'Could not send welcome to %1 at %2.', Customer.Name, Customer."E-Mail"); + Error(FailureWithPiiErr); + end; +} diff --git a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.good.al b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.good.al new file mode 100644 index 0000000..a3a4b25 --- /dev/null +++ b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.good.al @@ -0,0 +1,15 @@ +codeunit 50902 "Privacy Sample ErrorVsMsg Good" +{ + procedure ConfirmThenFail(var Customer: Record Customer) + var + ConfirmQst: Label 'Send welcome email to %1 at %2?', Comment = '%1 = name, %2 = email'; + GenericFailureErr: Label 'The welcome email could not be sent.'; + begin + // Confirm is not logged to telemetry. PII in the prompt is fine. + if not Confirm(ConfirmQst, false, Customer.Name, Customer."E-Mail") then + exit; + + // Error is logged. Keep PII out of the message. + Error(GenericFailureErr); + end; +} diff --git a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md new file mode 100644 index 0000000..cb3ec85 --- /dev/null +++ b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [error, message, confirm, notification, telemetry, pii] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Error logs to telemetry; Message, Confirm, and Notification do not + +## Description + +The privacy concern with user-facing text is not what the authenticated user sees — it is what the platform exports to telemetry. Error is captured automatically; Message, Confirm, StrMenu, and Notification are not. Reviews that flag PII in any user-facing dialog over-report. Reviews that ignore PII in Error under-report. The distinction is the delivery surface, not the presence of a person's name on screen. + +## Best Practice + +Free-text business content — customer names, email addresses, document numbers — is acceptable in Message, Confirm, and Notification. Treat Error text as if it will be read by telemetry consumers, because it will be. Use localized Labels with the fewest possible PII placeholders, or system identifiers (SystemId, primary key values) rather than personal data. + +See sample: `error-is-logged-to-telemetry-message-is-not.good.al`. + +## Anti Pattern + +Embedding customer emails, phone numbers, addresses, or names directly into Error strings — either as literals or via pre-built StrSubstNo output — because "the user will see this anyway." The user also sees Message and Confirm, but those are not logged. Error is. + +See sample: `error-is-logged-to-telemetry-message-is-not.bad.al`. diff --git a/microsoft/knowledge/privacy/flowfields-auto-inherit-systemmetadata.md b/microsoft/knowledge/privacy/flowfields-auto-inherit-systemmetadata.md new file mode 100644 index 0000000..5b049d3 --- /dev/null +++ b/microsoft/knowledge/privacy/flowfields-auto-inherit-systemmetadata.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [flowfield, flowfilter, dataclassification, systemmetadata, default] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# FlowFields and FlowFilters automatically inherit DataClassification SystemMetadata + +## Description + +FlowFields and FlowFilters are virtual — they carry no stored data of their own, and their values are computed on demand from the source table the CalcFormula references. The platform classifies them as SystemMetadata automatically and does not require (or respect) a per-field DataClassification declaration. Flagging a FlowField as missing DataClassification, or as under-classified because the computed value may be CustomerContent, is a false positive: the underlying source field carries the classification that matters, and that is what telemetry and compliance tooling inspects. + +## Best Practice + +Leave DataClassification off FlowFields and FlowFilters. If the computed value is sensitive, the fix is to ensure the source table's field has the correct classification. Verify source-field classification rather than trying to re-classify the computed view. + +## Anti Pattern + +Reporting "missing DataClassification" on a FlowField, or attempting to set a FlowField's DataClassification to CustomerContent because the SUM aggregates a sensitive amount. The declaration has no effect; the platform uses the source-field classification. diff --git a/microsoft/knowledge/privacy/in-memory-data-is-not-a-privacy-concern.md b/microsoft/knowledge/privacy/in-memory-data-is-not-a-privacy-concern.md new file mode 100644 index 0000000..415525f --- /dev/null +++ b/microsoft/knowledge/privacy/in-memory-data-is-not-a-privacy-concern.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [memory, dictionary, list, temporary-record, scope, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# In-memory variables are not a privacy concern in Business Central + +## Description + +Business Central runs in a managed server environment. Local variables, Dictionary, List, and temporary Record buffers exist only for the duration of the request or session; the runtime reclaims them when the scope exits. Memory dumps are not a realistic threat vector in this architecture, and flagging an in-memory collection of customer emails or names as a privacy issue misstates the product's security model. + +## Best Practice + +Focus privacy review on persistence, transit, and telemetry: what is written to tables, sent over the network, or logged. Treat in-memory handling of personal data as normal business functionality. When an in-memory buffer is copied into IsolatedStorage, a table, or a telemetry call, that downstream write is what gets reviewed. + +## Anti Pattern + +Flagging `Dictionary of [Code[20], Text]`, `List of [Text]`, or `Record Customer temporary` variables that hold customer data during a calculation as a privacy concern. The flag is a false positive that trains authors to avoid a normal pattern and distracts from the persistent storage that does matter. diff --git a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.bad.al b/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.bad.al new file mode 100644 index 0000000..cf1b8fa --- /dev/null +++ b/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.bad.al @@ -0,0 +1,18 @@ +table 50909 "Privacy Sample Override Bad" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Entry No."; Integer) { } + // Customer name inherits SystemMetadata from the table. Subject-access + // and retention tooling treats the value as system housekeeping. + field(2; "Customer Name"; Text[100]) { } + field(3; "E-Mail"; Text[80]) { } + field(4; "Logged At"; DateTime) { } + } + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} diff --git a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.good.al b/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.good.al new file mode 100644 index 0000000..2670601 --- /dev/null +++ b/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.good.al @@ -0,0 +1,23 @@ +table 50908 "Privacy Sample Override Good" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Customer Name"; Text[100]) + { + // Table default is SystemMetadata; this field is personal data. + DataClassification = CustomerContent; + } + field(3; "E-Mail"; Text[80]) + { + DataClassification = CustomerContent; + } + field(4; "Logged At"; DateTime) { } + } + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} diff --git a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.md b/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.md new file mode 100644 index 0000000..677e9c4 --- /dev/null +++ b/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [dataclassification, inheritance, table-level, field-level, override] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Override inherited DataClassification when a field doesn't fit the table default + +## Description + +When a table declares `DataClassification` at the table level, every field inherits that value unless the field declares its own. This is efficient for homogeneous tables — a SystemMetadata log table whose fields are all system-generated, a CustomerContent transaction table whose fields are all business data. It is a privacy regression when a table is classified SystemMetadata but contains a field that holds personal data: the field silently inherits the wrong classification, and telemetry tooling treats its content as safe to log when it is not. + +## Best Practice + +Review every field on a table with a table-level DataClassification. Fields whose content matches the table's default need no per-field declaration. Fields that carry a different kind of data — a customer name on an otherwise-system-metadata log table, a personal identifier on a mixed-content table — must declare their own DataClassification that overrides the table default. + +See sample: `override-inherited-dataclassification-per-field.good.al`. + +## Anti Pattern + +A table declared `DataClassification = SystemMetadata` with fields like `Customer Name`, `E-Mail`, `Phone No.` — the fields inherit SystemMetadata, which is wrong for CustomerContent. Subject-access-request and retention tooling treats the personal data as system housekeeping. + +See sample: `override-inherited-dataclassification-per-field.bad.al`. diff --git a/microsoft/knowledge/privacy/pages-displaying-permitted-data-is-not-a-privacy-concern.md b/microsoft/knowledge/privacy/pages-displaying-permitted-data-is-not-a-privacy-concern.md new file mode 100644 index 0000000..e28a0d6 --- /dev/null +++ b/microsoft/knowledge/privacy/pages-displaying-permitted-data-is-not-a-privacy-concern.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [page, display, permission, authenticated, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pages displaying data to permitted users are not a privacy concern + +## Description + +Every page in Business Central displays data to an authenticated user who holds the permissions required to see it. The permission system — table permissions, entitlements, field-level restrictions where configured — is the access-control boundary. Flagging a page for showing customer emails, names, addresses, document numbers, or system audit fields treats display as a leak when it is the product's intended function. + +## Best Practice + +Privacy review of pages is about data classification on the source table and about consent on outgoing integrations reached through page actions. Displaying business data to a user with permission to view it is correct behaviour, including on API pages that are gated by the same permission model. + +## Anti Pattern + +Reporting "customer email is shown on the page" or "user ID visible in the list" as privacy findings. The finding does not reflect a privacy regression and redirects the author toward hiding data that the permitted user is entitled to see. The same logic produces noise on Confirm, Message, and Notification that surface business identifiers. diff --git a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.bad.al b/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.bad.al new file mode 100644 index 0000000..0349acf --- /dev/null +++ b/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.bad.al @@ -0,0 +1,14 @@ +codeunit 50905 "Privacy Sample Consent Bad" +{ + procedure SyncToPartner(var Customer: Record Customer) + var + Client: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + begin + // Customer email and name sent externally with no Privacy Notice check + // anywhere in the reachable code path. + Content.WriteFrom(Customer."E-Mail"); + Client.Post('https://partner.example.com/sync', Content, Response); + end; +} diff --git a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.good.al b/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.good.al new file mode 100644 index 0000000..c3aec56 --- /dev/null +++ b/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.good.al @@ -0,0 +1,20 @@ +codeunit 50904 "Privacy Sample Consent Good" +{ + procedure SyncToPartner(var Customer: Record Customer) + var + PrivacyNotice: Codeunit "Privacy Notice"; + Client: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + PartnerNoticeIdTok: Label 'Contoso-PartnerSync', Locked = true; + ConsentRequiredErr: Label 'Consent is required before syncing to the external partner.'; + begin + if PrivacyNotice.GetPrivacyNoticeApprovalState(PartnerNoticeIdTok, false) <> + "Privacy Notice Approval State"::Agreed + then + Error(ConsentRequiredErr); + + Content.WriteFrom(Customer."No."); + Client.Post('https://partner.example.com/sync', Content, Response); + end; +} diff --git a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.md b/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.md new file mode 100644 index 0000000..2ac058a --- /dev/null +++ b/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [privacy-notice, consent, gdpr, httpclient, outgoing-request] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Check Privacy Notice consent before outgoing requests with customer data + +## Description + +Business Central ships a Privacy Notice framework for user consent to third-party integrations. When code sends personal data (emails, names, addresses) to an external service, the concern is not whether the data itself is compliant — the product handles that — but whether the code path has verified the user has agreed to the integration. Missing consent checks on new or modified outgoing paths is the privacy issue to flag; the presence of PII in the payload is not. + +## Best Practice + +Before an outgoing HttpClient call that carries customer data, verify consent via `Codeunit "Privacy Notice".GetPrivacyNoticeApprovalState()` for the integration's registered notice id. The check may live upstream (page OnOpenPage, wizard step) as long as every path that reaches the external call passes through it. Register new integrations via `Codeunit "Privacy Notice Registrations"`. + +See sample: `require-privacy-notice-consent-before-outgoing-requests.good.al`. + +## Anti Pattern + +Adding or modifying an outgoing integration and sending customer data without any `Privacy Notice` check in the reachable code path. Removing an existing consent check from an integration that still sends data externally falls in the same category. + +See sample: `require-privacy-notice-consent-before-outgoing-requests.bad.al`. diff --git a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.bad.al b/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.bad.al new file mode 100644 index 0000000..4ff958b --- /dev/null +++ b/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.bad.al @@ -0,0 +1,16 @@ +codeunit 50907 "Privacy Sample LastErr Bad" +{ + procedure LogFailure() + var + CategoryTok: Label 'Sync', Locked = true; + FailureTxt: Label 'Operation failed: %1', Comment = '%1 = last error text'; + begin + // GetLastErrorText(true) carries the call stack and field values from + // the failing context. Declared as SystemMetadata but the payload is CustomerContent. + Session.LogMessage( + '0000ABC', StrSubstNo(FailureTxt, GetLastErrorText(true)), + Verbosity::Error, + DataClassification::SystemMetadata, + TelemetryScope::ExtensionPublisher, 'Category', CategoryTok); + end; +} diff --git a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.good.al b/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.good.al new file mode 100644 index 0000000..1c3d8fc --- /dev/null +++ b/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.good.al @@ -0,0 +1,15 @@ +codeunit 50906 "Privacy Sample LastErr Good" +{ + procedure LogFailure() + var + CategoryTok: Label 'Sync', Locked = true; + GenericMsgTxt: Label 'Sync operation failed. See extended log for details.'; + begin + // Generic message, no GetLastErrorText. Detail goes to an internal log + // the telemetry pipeline does not receive. + Session.LogMessage( + '0000ABC', GenericMsgTxt, Verbosity::Error, + DataClassification::SystemMetadata, + TelemetryScope::ExtensionPublisher, 'Category', CategoryTok); + end; +} diff --git a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.md b/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.md new file mode 100644 index 0000000..496c0ac --- /dev/null +++ b/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [getlasterrortext, telemetry, callstack, dataclassification, pii] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Sanitize GetLastErrorText before sending to telemetry + +## Description + +`GetLastErrorText` and `GetLastErrorCallStack` return strings built from the failing call site's data — field values, record keys, customer names, filenames. Logging either to telemetry with `DataClassification::SystemMetadata` misstates the content: the actual values are CustomerContent or worse. The true classification is not always SystemMetadata, and silently mislabelling a CustomerContent payload as system data is the specific privacy regression to avoid. + +## Best Practice + +Log a generic error message and either omit GetLastErrorText entirely or classify the telemetry call as `DataClassification::CustomerContent`. Prefer `GetLastErrorText(false)` to exclude the call stack when the text is needed but the stack is not. When in doubt, log a generic summary and persist the detailed error separately in a restricted-access log the telemetry pipeline does not receive. + +See sample: `sanitize-getlasterrortext-before-telemetry.good.al`. + +## Anti Pattern + +`Session.LogMessage(..., StrSubstNo('Operation failed: %1', GetLastErrorText(true)), ..., DataClassification::SystemMetadata, ...)` — the classification is wrong for the payload, and the call stack typically carries customer data from the failing operation into the telemetry stream. + +See sample: `sanitize-getlasterrortext-before-telemetry.bad.al`. diff --git a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.bad.al b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.bad.al new file mode 100644 index 0000000..f2eb191 --- /dev/null +++ b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.bad.al @@ -0,0 +1,16 @@ +codeunit 50913 "Privacy Sample Telemetry Bad" +{ + procedure LogProcessed(var Customer: Record Customer) + var + CategoryTok: Label 'CustomerProcessing', Locked = true; + MsgTemplateTxt: Label 'Processed customer %1', Comment = '%1 = customer name'; + begin + // Declared SystemMetadata; payload is CustomerContent. The message is + // opaque text once built; the pipeline cannot redact. + Session.LogMessage( + '0000001', StrSubstNo(MsgTemplateTxt, Customer.Name), + Verbosity::Normal, + DataClassification::SystemMetadata, + TelemetryScope::All, 'Category', CategoryTok); + end; +} diff --git a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.good.al b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.good.al new file mode 100644 index 0000000..233e11d --- /dev/null +++ b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.good.al @@ -0,0 +1,17 @@ +codeunit 50912 "Privacy Sample Telemetry Good" +{ + procedure LogProcessed(var Customer: Record Customer) + var + CategoryTok: Label 'CustomerProcessing', Locked = true; + ProcessedMsgTxt: Label 'Customer record processed.'; + begin + // Generic message. Business identifier in a custom dimension, + // never a free-text personal name. + Session.LogMessage( + '0000001', ProcessedMsgTxt, Verbosity::Normal, + DataClassification::SystemMetadata, + TelemetryScope::ExtensionPublisher, + 'Category', CategoryTok, + 'CustomerNo', Customer."No."); + end; +} diff --git a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md new file mode 100644 index 0000000..042be42 --- /dev/null +++ b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [telemetry, session-logmessage, dataclassification, dimensions, pii] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Specify DataClassification on every telemetry call and keep PII out of the message + +## Description + +`Session.LogMessage` accepts a DataClassification parameter that governs how the platform handles the logged content in the telemetry pipeline. Omitting it is a schema violation the platform cannot repair later. Embedding personal data — emails, names, phone numbers, addresses, filenames of user uploads — in the message string also defeats classification, because the pipeline sees opaque text and cannot selectively redact. + +## Best Practice + +Pass DataClassification explicitly on every Session.LogMessage call. Keep the message a generic, non-identifying sentence and place structured values in custom dimensions where the classification applies per key. Business identifiers (Customer No., Document No., Vendor No.) are acceptable as dimensions; free-text personal data is not. + +See sample: `specify-dataclassification-on-every-telemetry-call.good.al`. + +## Anti Pattern + +`Session.LogMessage('0001', StrSubstNo('Customer %1 processed', Customer.Name), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All)` — the declared classification is SystemMetadata but the message carries CustomerContent. The payload is logged with the wrong tag; downstream consumers treat it as safe when it is not. + +See sample: `specify-dataclassification-on-every-telemetry-call.bad.al`. diff --git a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.bad.al b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.bad.al new file mode 100644 index 0000000..f162f6a --- /dev/null +++ b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.bad.al @@ -0,0 +1,14 @@ +codeunit 50901 "Privacy Sample StrSubstNo Bad" +{ + procedure FailCustomer(var Customer: Record Customer) + var + ErrorMsg: Text; + begin + // Platform receives a plain Text string. It cannot inspect fields, + // cannot classify, cannot strip. The email and address reach telemetry. + ErrorMsg := StrSubstNo( + 'Customer %1 (%2) at %3 has invalid data', + Customer.Name, Customer."E-Mail", Customer.Address); + Error(ErrorMsg); + end; +} diff --git a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.good.al b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.good.al new file mode 100644 index 0000000..3ced013 --- /dev/null +++ b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.good.al @@ -0,0 +1,11 @@ +codeunit 50900 "Privacy Sample StrSubstNo Good" +{ + procedure FailCustomer(var Customer: Record Customer) + var + CustomerDataInvalidErr: Label 'Customer %1 has invalid data.', Comment = '%1 = Customer No.'; + begin + // Platform sees the Label and the field reference. It inspects the + // field's DataClassification and handles telemetry correctly. + Error(CustomerDataInvalidErr, Customer."No."); + end; +} diff --git a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md new file mode 100644 index 0000000..8210ec8 --- /dev/null +++ b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [strsubstno, error, telemetry, dataclassification, pii] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pre-building Error text with StrSubstNo defeats platform PII stripping + +## Description + +Error messages are captured by platform telemetry. When Error receives a format template and field references as substitution arguments (Error('... %1 ...', Customer."No.")), the platform inspects each field's DataClassification and omits sensitive values from telemetry automatically. When the caller pre-builds the message with StrSubstNo and then passes the resulting Text to Error, the platform sees a plain string with no field context and logs the whole thing verbatim — any PII already baked in is exported to telemetry. + +## Best Practice + +Pass the template and the field references directly to Error. Declare the template as a Label with a Comment describing each placeholder. The platform's field-aware classification logic then takes care of what reaches telemetry. + +See sample: `strsubstno-prebuild-breaks-error-telemetry-classification.good.al`. + +## Anti Pattern + +Assigning the output of StrSubstNo to a Text variable and passing that variable to Error. Every substituted value is now part of an opaque string; the platform cannot classify it and logs everything. + +See sample: `strsubstno-prebuild-breaks-error-telemetry-classification.bad.al`. diff --git a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.bad.al b/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.bad.al new file mode 100644 index 0000000..7659ea9 --- /dev/null +++ b/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.bad.al @@ -0,0 +1,16 @@ +tableextension 51303 "Sec Sample VTR Bad" extends "Sales Header" +{ + fields + { + // Editable user input with validation suppressed and no fallback check. + // The user can type any string; downstream Get against Customer will fail + // or return a wrong row. + field(50102; "Customer No."; Code[20]) + { + Caption = 'Customer no.'; + DataClassification = CustomerContent; + TableRelation = Customer."No."; + ValidateTableRelation = false; + } + } +} diff --git a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.good.al b/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.good.al new file mode 100644 index 0000000..9b76762 --- /dev/null +++ b/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.good.al @@ -0,0 +1,24 @@ +tableextension 51302 "Sec Sample VTR Good" extends "Sales Header" +{ + fields + { + // User-editable field keeps ValidateTableRelation default (true). + field(50100; "External Customer Ref"; Code[50]) + { + Caption = 'External customer reference'; + DataClassification = CustomerContent; + TableRelation = Customer."No."; + } + + // System-controlled field: validation bypass is acceptable because + // the value is populated by controlled upstream code, not the user. + field(50101; "System Batch Id"; Code[20]) + { + Caption = 'System batch ID'; + DataClassification = SystemMetadata; + TableRelation = "Job Queue Entry".ID; + ValidateTableRelation = false; + Editable = false; + } + } +} diff --git a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.md b/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.md new file mode 100644 index 0000000..0cec0fd --- /dev/null +++ b/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: security +keywords: [validatetablerelation, user-input, lookup, integrity, validation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not set ValidateTableRelation = false on fields that accept user input + +## Description + +`TableRelation` on a field tells the platform that the value must exist as a primary key in the related table. `ValidateTableRelation = false` suppresses that check at validation time. On system-populated fields — values the code sets from a controlled source and never displays as editable — the suppression is acceptable because the integrity guarantee comes from the upstream writer. On a field the user types into (a page field, an import column, an API payload), disabling the validation means any value at all can be written: a non-existent customer number, a typo, a deliberate bad value. The table no longer enforces the relation, and downstream code that Gets the related row with an unguarded lookup breaks. + +## Best Practice + +Leave `ValidateTableRelation = true` (the default) on any field the user can set. When the default would produce unhelpful behaviour — a transient lookup that does not yet exist at validation time, a reference that uses a non-primary-key column — handle it with a targeted OnValidate trigger that performs the semantic check explicitly. Use `ValidateTableRelation = false` only when the field is genuinely system-controlled and the writer has already validated the reference. + +See sample: `do-not-disable-validatetablerelation-on-user-input.good.al`. + +## Anti Pattern + +A `Customer No.` field on an editable page with `TableRelation = Customer."No."` and `ValidateTableRelation = false` and no OnValidate fallback. The user can type any string; the platform accepts it; a later Get against Customer fails or returns the wrong row. + +See sample: `do-not-disable-validatetablerelation-on-user-input.bad.al`. diff --git a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.bad.al b/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.bad.al new file mode 100644 index 0000000..3ed2bff --- /dev/null +++ b/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.bad.al @@ -0,0 +1,14 @@ +codeunit 51301 "Sec Sample EnvGuid Bad" +{ + procedure GetTenantId(): Text + begin + // Tenant GUID hardcoded. Extension works in one environment, fails in every other. + exit('{12345678-1234-1234-1234-123456789012}'); + end; + + procedure GetAadApplicationId(): Text + begin + // AAD application GUID hardcoded. Same problem, surfaces as an authentication error. + exit('{87654321-4321-4321-4321-210987654321}'); + end; +} diff --git a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.good.al b/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.good.al new file mode 100644 index 0000000..e19a771 --- /dev/null +++ b/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.good.al @@ -0,0 +1,16 @@ +codeunit 51300 "Sec Sample EnvGuid Good" +{ + procedure KnownSystemId(): Guid + begin + // Stable across tenants and versions — Base Application Id. + exit('{437dbf0e-84ff-417a-965d-ed2bb9650972}'); + end; + + procedure GetTenantId(): Text + var + EnvironmentInformation: Codeunit "Environment Information"; + begin + // Environment-specific values are retrieved at runtime. + exit(EnvironmentInformation.GetTenantId()); + end; +} diff --git a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.md b/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.md new file mode 100644 index 0000000..7fb3775 --- /dev/null +++ b/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: security +keywords: [guid, tenant-id, aad, environment, hardcoded] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Hardcoded GUIDs are only safe for well-known system identifiers + +## Description + +AL code sometimes carries hardcoded GUIDs. Some are platform-defined, stable across tenants and versions, and legitimately constant — the Base Application's ApplicationId (`{437dbf0e-84ff-417a-965d-ed2bb9650972}`) is the canonical example. Others identify a specific tenant, a specific Azure Active Directory application, or a specific environment; these look identical at the source-code level but are environment-bound and break the moment the extension is deployed anywhere else. Shipping an environment-specific GUID as a constant effectively locks the extension to one environment, and the failure mode in other tenants is usually an authentication error with no code-level signal pointing at the literal. + +## Best Practice + +Hardcoded GUIDs are acceptable for well-known system identifiers that are stable across environments — document the identifier with a comment that names what it refers to. For tenant IDs, AAD application IDs, API subscription IDs, and any value that varies by deployment, retrieve at runtime from IsolatedStorage, configuration tables, or the platform APIs that expose the current tenant context. + +See sample: `do-not-hardcode-environment-specific-guids.good.al`. + +## Anti Pattern + +`TenantId := '{12345678-1234-1234-1234-123456789012}';` or `AadApplicationId := '{87654321-...}';` inline in a codeunit. The extension authenticates in one environment and fails in every other; debugging starts from an AAD error message that does not mention the literal. + +See sample: `do-not-hardcode-environment-specific-guids.bad.al`. diff --git a/microsoft/knowledge/style/apply-approved-label-suffixes.bad.al b/microsoft/knowledge/style/apply-approved-label-suffixes.bad.al new file mode 100644 index 0000000..2893eb0 --- /dev/null +++ b/microsoft/knowledge/style/apply-approved-label-suffixes.bad.al @@ -0,0 +1,17 @@ +codeunit 51101 "Style Sample LabelSuffix Bad" +{ + procedure Example() + var + CannotDeleteLine: Label 'Cannot delete this line.'; + Text000: Label 'Update complete'; + UpdateLocation: Label 'Update location?'; + WrongSuffixTok: Label 'Customer %1 not found.', Comment = '%1 = Customer No.'; + CustomerNo: Code[20]; + begin + Error(CannotDeleteLine); + Message(Text000); + if Confirm(UpdateLocation) then + ; + Error(WrongSuffixTok, CustomerNo); + end; +} diff --git a/microsoft/knowledge/style/apply-approved-label-suffixes.good.al b/microsoft/knowledge/style/apply-approved-label-suffixes.good.al new file mode 100644 index 0000000..6557feb --- /dev/null +++ b/microsoft/knowledge/style/apply-approved-label-suffixes.good.al @@ -0,0 +1,20 @@ +codeunit 51100 "Style Sample LabelSuffix Good" +{ + procedure Example() + var + UpdateCompleteMsg: Label 'Update complete.'; + CannotDeleteLineErr: Label 'Cannot delete this line.'; + UpdateLocationQst: Label 'Update location?'; + CustomerNameLbl: Label 'Customer Name'; + HttpsMethodTok: Label 'GET', Locked = true; + TelemetryCustomerUpdatedTxt: Label 'Customer updated.'; + begin + Message(UpdateCompleteMsg); + if Confirm(UpdateLocationQst) then + ; + Session.LogMessage('0001', TelemetryCustomerUpdatedTxt, + Verbosity::Normal, DataClassification::SystemMetadata, + TelemetryScope::ExtensionPublisher); + Error(CannotDeleteLineErr); + end; +} diff --git a/microsoft/knowledge/style/apply-approved-label-suffixes.md b/microsoft/knowledge/style/apply-approved-label-suffixes.md new file mode 100644 index 0000000..59a8f72 --- /dev/null +++ b/microsoft/knowledge/style/apply-approved-label-suffixes.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [label, textconst, suffix, msg, err, qst, tok, lbl, txt, aa0074] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Suffix every Label and TextConst with its approved usage tag + +## Description + +CodeCop rule AA0074 requires every Label and TextConst to carry a suffix indicating how the value is consumed: `Msg` for Message calls, `Err` for Error calls, `Qst` for Confirm or StrMenu prompts, `Tok` for locked tokens (URLs, JSON keys, short literals with `Locked = true`), `Lbl` for captions and tooltips, and `Txt` for telemetry strings. The suffix is not decoration — it is how the compiler, linter, and reviewer detect misuse (a `Tok` value passed to `Error`, a `Msg` used as an error label). The cost of adopting the convention is one short suffix per declaration; the cost of ignoring it is that every reviewer has to inspect every call site to judge appropriateness. + +## Best Practice + +Name every Label and TextConst with one of `Msg`, `Err`, `Qst`, `Tok`, `Lbl`, or `Txt` at the end. Pick the suffix that matches the consuming call, not the look of the string. When multiple suffixes are grammatically valid (`Tok` vs `Lbl` for a short caption on a locked token) the choice is a judgment call; the violation is missing a suffix or using one inconsistent with the call site. + +See sample: `apply-approved-label-suffixes.good.al`. + +## Anti Pattern + +`CannotDeleteLine: Label 'Cannot delete this line.';` — no suffix, used with Error. `Text000: Label 'Update complete';` — generic name with no suffix at all. `WrongSuffixTok: Label 'Customer %1 not found.'` used with Error — a Tok suffix on an error label. + +See sample: `apply-approved-label-suffixes.bad.al`. diff --git a/microsoft/knowledge/style/follow-api-page-naming-rules.bad.al b/microsoft/knowledge/style/follow-api-page-naming-rules.bad.al new file mode 100644 index 0000000..155d263 --- /dev/null +++ b/microsoft/knowledge/style/follow-api-page-naming-rules.bad.al @@ -0,0 +1,22 @@ +page 51103 "Style Sample ApiPage Bad" +{ + PageType = API; + APIPublisher = 'Contoso-App'; // hyphen not allowed + APIGroup = 'app_1'; // underscore not allowed + APIVersion = 'v2'; // missing minor version + EntityName = 'customers'; // should be singular + EntitySetName = 'customer'; // should be plural + SourceTable = Customer; + // DelayedInsert omitted; composite-key inserts misbehave + + layout + { + area(Content) + { + repeater(Group) + { + field(number; Rec."No.") { } + } + } + } +} diff --git a/microsoft/knowledge/style/follow-api-page-naming-rules.good.al b/microsoft/knowledge/style/follow-api-page-naming-rules.good.al new file mode 100644 index 0000000..8e1bec5 --- /dev/null +++ b/microsoft/knowledge/style/follow-api-page-naming-rules.good.al @@ -0,0 +1,25 @@ +page 51102 "Style Sample ApiPage Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v2.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; + ODataKeyFields = SystemId; + + layout + { + area(Content) + { + repeater(Group) + { + field(systemId; Rec.SystemId) { } + field(number; Rec."No.") { } + field(displayName; Rec.Name) { } + } + } + } +} diff --git a/microsoft/knowledge/style/follow-api-page-naming-rules.md b/microsoft/knowledge/style/follow-api-page-naming-rules.md new file mode 100644 index 0000000..a203d38 --- /dev/null +++ b/microsoft/knowledge/style/follow-api-page-naming-rules.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, apiversion, entityname, entitysetname, apipublisher, apigroup, delayedinsert] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# API pages follow strict naming and property rules that differ from regular pages + +## Description + +Pages declared `PageType = API` are exposed through the OData API surface. The platform enforces a set of conventions that regular pages do not share: `APIPublisher`, `APIGroup`, `EntityName`, and `EntitySetName` must be camelCase alphanumeric only — no spaces, hyphens, or underscores. `APIVersion` must match the pattern `vX.Y` (for example `v2.0`) or the literal `beta`. `EntityName` is the singular form (`customer`); `EntitySetName` is the plural (`customers`). `DelayedInsert = true` is effectively required for the OData insert workflow to behave correctly on composite keys. These rules are platform-enforced and tooling-enforced; violations produce runtime errors or consumer-visible inconsistencies rather than soft warnings. + +## Best Practice + +For every API page: camelCase alphanumeric API properties; `APIVersion` as `vX.Y` or `beta`; singular `EntityName` and plural `EntitySetName`; `DelayedInsert = true`. Keep these properties together near the top of the page definition so reviewers can check the set at a glance. + +See sample: `follow-api-page-naming-rules.good.al`. + +## Anti Pattern + +`APIPublisher = 'Contoso-App'` (hyphen rejected), `EntityName = 'customers'` and `EntitySetName = 'customer'` (swapped), `APIVersion = 'v2'` (missing minor version), `DelayedInsert` omitted. Each violation surfaces only when a consumer exercises the endpoint. + +See sample: `follow-api-page-naming-rules.bad.al`. diff --git a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.bad.al b/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.bad.al new file mode 100644 index 0000000..b114696 --- /dev/null +++ b/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.bad.al @@ -0,0 +1,15 @@ +codeunit 51107 "Style Sample LabelProps Bad" +{ + procedure Example() + var + // Two placeholders, no Comment. The translator has to guess which + // identifier maps to %1 and which to %2. + CustomerLocationErr: Label 'Customer %1 not found in %2.'; + // URL without Locked: enters the localization pipeline, may be translated. + HttpsUrlLbl: Label 'https://example.com'; + CustomerNo: Code[20]; + LocationCode: Code[10]; + begin + Error(CustomerLocationErr, CustomerNo, LocationCode); + end; +} diff --git a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.good.al b/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.good.al new file mode 100644 index 0000000..d462a3f --- /dev/null +++ b/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.good.al @@ -0,0 +1,14 @@ +codeunit 51106 "Style Sample LabelProps Good" +{ + procedure Example() + var + CustomerNotFoundErr: Label 'Customer %1 does not exist for document %2.', + Comment = '%1 = Customer No., %2 = Document No.'; + HttpsProtocolTok: Label 'HTTPS', Locked = true; + ShortDescLbl: Label 'Description text', MaxLength = 50; + CustomerNo: Code[20]; + DocumentNo: Code[20]; + begin + Error(CustomerNotFoundErr, CustomerNo, DocumentNo); + end; +} diff --git a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.md b/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.md new file mode 100644 index 0000000..9064608 --- /dev/null +++ b/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [label, placeholder, comment, locked, maxlength, localization] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Label placeholders need a Comment; locked strings need Locked = true + +## Description + +AL Labels accept optional properties — `Comment`, `Locked`, `MaxLength` — that travel with the string to localization. The Comment is the translator's only signal for what `%1` and `%2` mean; without it, `'Document %1 has errors in %2.'` translates unpredictably because the translator has to guess whether %1 is a document number, document type, or document name. `Locked = true` marks a string as non-translatable — URLs, JSON keys, short command tokens — and keeps the localization pipeline from translating literals that must stay verbatim. `MaxLength` limits how much of the label survives truncation. The Comment is required whenever placeholders are not self-evident; Locked is required on any non-text value. + +## Best Practice + +For placeholders, write `Comment = '%1 = Customer No., %2 = Document Type'` alongside the Label. For URLs, HTTP methods, JSON keys, and similar literals, set `Locked = true` and use the `Tok` suffix (see `apply-approved-label-suffixes`). For captions with a tight visual budget, set `MaxLength` to the enforceable length. When the placeholder meaning is obvious (`'Customer %1 not found.'`) the Comment is optional. + +See sample: `include-comment-on-labels-with-placeholders.good.al`. + +## Anti Pattern + +`CustomerLocationErr: Label 'Customer %1 not found in %2.';` with no Comment — translators will not know which identifier maps to which placeholder. `HttpsUrl: Label 'https://example.com';` with no Locked — the URL enters the localization pipeline and may be translated into a broken address. + +See sample: `include-comment-on-labels-with-placeholders.bad.al`. diff --git a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.bad.al b/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.bad.al new file mode 100644 index 0000000..4931672 --- /dev/null +++ b/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.bad.al @@ -0,0 +1,22 @@ +table 51113 "Style Sample Option Bad" +{ + fields + { + field(1; "Entry No."; Integer) { } + field(10; Priority; Option) + { + // Four members, three captions. Critical renders with no caption. + OptionMembers = Low,Medium,High,Critical; + OptionCaption = 'Low,Medium,High'; + } + field(20; Status; Option) + { + // Missing OptionCaption entirely. + OptionMembers = Open,Released,Pending; + } + } + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} diff --git a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.good.al b/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.good.al new file mode 100644 index 0000000..fda6fa3 --- /dev/null +++ b/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.good.al @@ -0,0 +1,21 @@ +table 51112 "Style Sample Option Good" +{ + fields + { + field(1; "Entry No."; Integer) { } + field(10; Priority; Option) + { + OptionMembers = Low,Medium,High,Critical; + OptionCaption = 'Low,Medium,High,Critical'; + } + field(20; Status; Option) + { + OptionMembers = Open,Released,Pending; + OptionCaption = 'Open,Released,Pending'; + } + } + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} diff --git a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.md b/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.md new file mode 100644 index 0000000..6f81865 --- /dev/null +++ b/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [option, optionmembers, optioncaption, aa0221, aa0223, aa0224] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# OptionCaption must list exactly as many captions as OptionMembers + +## Description + +Option fields declare their values in `OptionMembers` and their localized display text in `OptionCaption`. The two lists are positionally paired — the Nth caption maps to the Nth member — and a mismatch either in count or in intent produces a field that renders blank for some values or shows the wrong caption for others. CodeCop rules AA0221, AA0223, and AA0224 flag the variants of this mistake: missing OptionCaption entirely on non-table-sourced option fields, OptionCaption with a different element count than OptionMembers, and OptionCaption content that does not correspond to the member names. + +## Best Practice + +Whenever OptionMembers is declared, declare OptionCaption with the same number of entries in the same order. For table-sourced option fields, the base table's caption applies and a per-page override is usually unnecessary — the rule applies to option fields defined in pages, reports, and non-table sources. + +See sample: `match-optioncaption-count-to-optionmembers.good.al`. + +## Anti Pattern + +`OptionMembers = Low,Medium,High,Critical;` paired with `OptionCaption = 'Low,Medium,High';` — three captions for four members. `Critical` rows render with the empty caption, or fall back to the member name, depending on where the option is displayed. + +See sample: `match-optioncaption-count-to-optionmembers.bad.al`. diff --git a/microsoft/knowledge/style/name-files-as-object-dot-type-dot-al.md b/microsoft/knowledge/style/name-files-as-object-dot-type-dot-al.md new file mode 100644 index 0000000..8dd4f56 --- /dev/null +++ b/microsoft/knowledge/style/name-files-as-object-dot-type-dot-al.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: style +keywords: [file-name, convention, object-type, al-project] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Name AL files as `..al` + +## Description + +Business Central AL projects follow a consistent file-naming convention: the file name is the object's name, followed by a dot, followed by the object type (`Page`, `Codeunit`, `Table`, `Report`, `Enum`, etc.), followed by `.al`. `CustomerCard.Page.al`, `PostSalesInvoice.Codeunit.al`, `SalesLine.Table.al`. The convention produces an alphabetically-ordered folder that groups all of an entity's objects (`SalesLine.Table.al`, `SalesLine.TableExt.al`, `SalesLineCard.Page.al`) next to each other, and makes navigation by file name in large repos predictable. + +## Best Practice + +Match the file name to the object declaration: PascalCase name, type segment, `.al`. Use `TableExt`, `PageExt`, `EnumExt` for the corresponding extension types. When multiple objects share a file (generally discouraged), name the file after the primary object. + +## Anti Pattern + +`customer_page.al`, `PostSalesInvoiceLogic.al`, `tests_noSeries.al` — all three violate the convention. The first uses snake_case, the second adds a descriptive suffix after the object name, the third prefixes the type instead of suffixing it. Tooling that expects the convention (AL-Go scaffolding, navigation helpers, diff conventions) then misbehaves on these files. diff --git a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.bad.al b/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.bad.al new file mode 100644 index 0000000..947caa1 --- /dev/null +++ b/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.bad.al @@ -0,0 +1,13 @@ +codeunit 51115 "Style Sample ErrorParams Bad" +{ + procedure Fail(CustomerNo: Code[20]) + var + CustomerNotFoundErr: Label 'Customer %1 does not exist.', Comment = '%1 = Customer No.'; + begin + // Pre-built Text to Error: translation skipped, telemetry opaque. + Error(StrSubstNo(CustomerNotFoundErr, CustomerNo)); + + // Concatenation: translation skipped, hard-coded delimiters baked in. + Error('Customer ' + CustomerNo + ' not found'); + end; +} diff --git a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.good.al b/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.good.al new file mode 100644 index 0000000..57a1775 --- /dev/null +++ b/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.good.al @@ -0,0 +1,11 @@ +codeunit 51114 "Style Sample ErrorParams Good" +{ + procedure Fail(CustomerNo: Code[20]; DocumentNo: Code[20]) + var + CustomerNotFoundErr: Label 'Customer %1 does not exist for document %2.', + Comment = '%1 = Customer No., %2 = Document No.'; + begin + // Label + arguments passed directly. Translations apply; telemetry classifies per field. + Error(CustomerNotFoundErr, CustomerNo, DocumentNo); + end; +} diff --git a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.md b/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.md new file mode 100644 index 0000000..b7d758b --- /dev/null +++ b/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [error, label, strsubstno, concatenation, telemetry, aa0216, aa0217] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pass Error parameters directly to the Label; do not pre-build with StrSubstNo or concatenation + +## Description + +`Error` accepts a Label and its substitution parameters directly (`Error(CustomerNotFoundErr, CustomerNo, DocumentNo)`). Pre-building the message via `StrSubstNo` and passing the resulting Text, or concatenating parts with `+` and passing the result, compiles but produces two distinct regressions. The localization pipeline can only translate the Label; a pre-built Text is passed through untouched, so non-English users see the English template. Platform telemetry inspects the Label's placeholder arguments for DataClassification; a pre-built Text is opaque, so PII in the arguments is logged verbatim (see `strsubstno-prebuild-breaks-error-telemetry-classification` in the privacy domain). + +## Best Practice + +Declare the Label with placeholders and pass arguments directly to Error: `Error(CustomerNotFoundErr, CustomerNo, DocumentNo)`. Use `Comment` on the Label to document each placeholder (see `include-comment-on-labels-with-placeholders`). `Error('')` is acceptable when the caller is responsible for the surfaced error. + +See sample: `pass-parameters-directly-to-error-no-strsubstno.good.al`. + +## Anti Pattern + +`Error(StrSubstNo(CustomerNotFoundErr, CustomerNo))` — loses translation. `Error(CustomerNotFoundErr + ': ' + CustomerNo)` — loses translation, concatenates hard-coded delimiters. `Error('Customer ' + CustomerNo + ' not found')` — uses no Label at all. + +See sample: `pass-parameters-directly-to-error-no-strsubstno.bad.al`. diff --git a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.bad.al b/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.bad.al new file mode 100644 index 0000000..ba94df9 --- /dev/null +++ b/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.bad.al @@ -0,0 +1,17 @@ +codeunit 51105 "Style Sample TempPrefix Bad" +{ + procedure BuildWorkingSet() + var + WIPBuffer: Record "Job WIP Buffer" temporary; + Customer: Record Customer; + begin + // Call sites read as persistent. A reviewer cannot tell at a glance + // whether DeleteAll hits the database or the in-memory buffer. + WIPBuffer.DeleteAll(); + if Customer.FindSet() then + repeat + WIPBuffer.Init(); + WIPBuffer.Insert(); + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.good.al b/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.good.al new file mode 100644 index 0000000..1f7d090 --- /dev/null +++ b/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.good.al @@ -0,0 +1,16 @@ +codeunit 51104 "Style Sample TempPrefix Good" +{ + procedure BuildWorkingSet() + var + TempJobWIPBuffer: Record "Job WIP Buffer" temporary; + Customer: Record Customer; + begin + // Every read site shows whether the variable is temporary. + TempJobWIPBuffer.DeleteAll(); + if Customer.FindSet() then + repeat + TempJobWIPBuffer.Init(); + TempJobWIPBuffer.Insert(); + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.md b/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.md new file mode 100644 index 0000000..36d8a6e --- /dev/null +++ b/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [temporary, record, variable, prefix, naming, temp] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefix temporary record variables with "Temp" + +## Description + +A `Record X temporary` variable behaves differently from a persistent Record variable of the same type: Insert/Modify/Delete mutate an in-memory buffer, not the underlying table. Code that mixes persistent and temporary variables of the same type is a recurring source of data-loss bugs — a helper that does `DeleteAll` on what the caller believed was a temporary buffer wipes the real table. The convention across Business Central is to prefix every temporary record variable with `Temp` (`TempJobWIPBuffer`, `TempSalesLine`, `TempCustomer`) so the distinction is visible at every read site, not only at the declaration. + +## Best Practice + +Prefix every temporary-record variable with `Temp`. The prefix goes on the variable name, not the type; the `temporary` keyword remains on the declaration. Matching the prefix against the declaration makes it a one-line check in code review: if the name starts with `Temp`, the declaration ends in `temporary`, and vice versa. + +See sample: `prefix-temporary-record-variables-with-temp.good.al`. + +## Anti Pattern + +`WIPBuffer: Record "Job WIP Buffer" temporary` — the variable reads like a persistent record in every call site below the declaration. A reviewer scanning a mutation call (`WIPBuffer.DeleteAll()`) cannot tell from the call site whether the effect is in-memory or production. + +See sample: `prefix-temporary-record-variables-with-temp.bad.al`. diff --git a/microsoft/knowledge/style/require-parentheses-on-function-calls.bad.al b/microsoft/knowledge/style/require-parentheses-on-function-calls.bad.al new file mode 100644 index 0000000..8a0dba6 --- /dev/null +++ b/microsoft/knowledge/style/require-parentheses-on-function-calls.bad.al @@ -0,0 +1,13 @@ +codeunit 51119 "Style Sample Parentheses Bad" +{ + procedure Example(var Customer: Record Customer) + var + TempBuffer: Record "Integer" temporary; + begin + // Parentheses omitted. The call site reads like a field access. + Customer.Init; + TempBuffer.DeleteAll; + if Customer.FindFirst then + ; + end; +} diff --git a/microsoft/knowledge/style/require-parentheses-on-function-calls.good.al b/microsoft/knowledge/style/require-parentheses-on-function-calls.good.al new file mode 100644 index 0000000..358ffe5 --- /dev/null +++ b/microsoft/knowledge/style/require-parentheses-on-function-calls.good.al @@ -0,0 +1,12 @@ +codeunit 51118 "Style Sample Parentheses Good" +{ + procedure Example(var Customer: Record Customer) + var + TempBuffer: Record "Integer" temporary; + begin + Customer.Init(); + TempBuffer.DeleteAll(); + if Customer.FindFirst() then + ; + end; +} diff --git a/microsoft/knowledge/style/require-parentheses-on-function-calls.md b/microsoft/knowledge/style/require-parentheses-on-function-calls.md new file mode 100644 index 0000000..3c1916b --- /dev/null +++ b/microsoft/knowledge/style/require-parentheses-on-function-calls.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [parentheses, function-call, aa0008, invocation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every function call carries parentheses, even with no arguments + +## Description + +AL allows `Customer.Init`, `TempBuffer.DeleteAll`, and `Customer.FindFirst` without trailing parentheses when the method takes no parameters. CodeCop rule AA0008 requires the parentheses anyway. The reason is readability: without `()`, the reader has to know the member is a method and not a property — an ambiguity that resolves differently for the platform's own APIs (FindFirst is a method; `Name` is a field). With `()`, the call site is visibly a method invocation and a simple grep for `Init(` or `DeleteAll(` finds every usage. + +## Best Practice + +Always write parentheses on method calls, even when empty: `Customer.Init()`, `TempBuffer.DeleteAll()`, `if Customer.FindFirst() then`. Apply the rule to platform methods and to user-defined procedures alike. + +See sample: `require-parentheses-on-function-calls.good.al`. + +## Anti Pattern + +`Customer.Init;`, `TempBuffer.DeleteAll;`, `if Customer.FindFirst then` — all three compile but obscure what is a call and what is a field access. The inconsistency compounds when the same codebase has both conventions. + +See sample: `require-parentheses-on-function-calls.bad.al`. diff --git a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.bad.al b/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.bad.al new file mode 100644 index 0000000..5492368 --- /dev/null +++ b/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.bad.al @@ -0,0 +1,12 @@ +codeunit 51111 "Style Sample FieldCaption Bad" +{ + procedure Example(var SalesLine: Record "Sales Line") + var + UpdateLocationQst: Label 'Update the %1?', Comment = '%1 = field'; + begin + // FieldName/TableName return English identifiers. User with a non-English + // locale sees the English "Location Code" inside an otherwise translated dialog. + if not Confirm(UpdateLocationQst, true, SalesLine.FieldName("Location Code")) then + exit; + end; +} diff --git a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.good.al b/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.good.al new file mode 100644 index 0000000..cf7693e --- /dev/null +++ b/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.good.al @@ -0,0 +1,13 @@ +codeunit 51110 "Style Sample FieldCaption Good" +{ + procedure Example(var SalesLine: Record "Sales Line") + var + UpdateLocationQst: Label 'Update the %1?', Comment = '%1 = field caption'; + TableUpdatedMsg: Label 'Updated %1.', Comment = '%1 = table caption'; + begin + // Captions are localized for the current user's language. + if not Confirm(UpdateLocationQst, true, SalesLine.FieldCaption("Location Code")) then + exit; + Message(TableUpdatedMsg, SalesLine.TableCaption()); + end; +} diff --git a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.md b/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.md new file mode 100644 index 0000000..2bd8d6f --- /dev/null +++ b/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [fieldcaption, tablecaption, fieldname, tablename, localization, user-message] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use FieldCaption and TableCaption in user messages, not FieldName and TableName + +## Description + +`FieldName` and `TableName` return the object's internal identifier in English — the name the developer typed into the declaration. `FieldCaption` and `TableCaption` return the translated caption for the current user's language. In user-facing messages, errors, confirmations, and notifications, the two pairs diverge the moment the user is running a non-English locale: `FieldName("Location Code")` reads `Location Code` in every language, while `FieldCaption("Location Code")` reads the translated equivalent. Using the wrong one leaks the English identifier into a localized UI and defeats the product's translation work. + +## Best Practice + +In any string the user will read, use `FieldCaption()` and `TableCaption`. Reserve `FieldName` and `TableName` for diagnostic and telemetry contexts where the stable English identifier is preferable. The same rule applies to `XmlPort`, `Query`, and other objects with a caption/name pair. + +See sample: `use-fieldcaption-and-tablecaption-in-user-messages.good.al`. + +## Anti Pattern + +`Confirm(UpdateLocationQst, true, FieldName("Location Code"))`, `Message('Updated %1', TableName())` — both surface English identifiers to a user whose entire UI is in a different language. + +See sample: `use-fieldcaption-and-tablecaption-in-user-messages.bad.al`. diff --git a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.bad.al b/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.bad.al new file mode 100644 index 0000000..1065a25 --- /dev/null +++ b/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.bad.al @@ -0,0 +1,10 @@ +codeunit 51109 "Style Sample NamedInvoke Bad" +{ + procedure Example(var SalesShptLine: Record "Sales Shipment Line") + begin + // Numeric ID. The reader has to look up 525 and 206 to know what is called. + // If either object is renumbered in a future release, this call silently retargets. + Page.RunModal(525, SalesShptLine); + Report.Run(206, true); + end; +} diff --git a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.good.al b/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.good.al new file mode 100644 index 0000000..634c202 --- /dev/null +++ b/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.good.al @@ -0,0 +1,9 @@ +codeunit 51108 "Style Sample NamedInvoke Good" +{ + procedure Example(var SalesShptLine: Record "Sales Shipment Line") + begin + // Named invocation: reviewer sees the object, rename of 525 cannot retarget. + Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine); + Report.Run(Report::"Sales - Invoice", true); + end; +} diff --git a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.md b/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.md new file mode 100644 index 0000000..db62cff --- /dev/null +++ b/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [object-id, page-run, report-run, codeunit-run, named-invocation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Invoke objects by name, not by numeric ID + +## Description + +AL supports calling `Page.RunModal(525, ...)` or `Report.Run(206, ...)` with a bare numeric ID. The platform accepts the number, but the call site loses every signal that makes the code reviewable and refactor-safe: the reader cannot tell which object is being invoked without looking up 525 in the object catalog, and the renumbering of an object in a future release (legal in AL — IDs are not a stable contract) silently retargets the call to a different object. The `Page::"..."` / `Report::"..."` syntax compiles to the same runtime call but makes the target explicit and binds by name, which is the stable identity. + +## Best Practice + +Write `Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine)` and `Report.Run(Report::"Sales - Invoice", true)`. Apply the same rule to `Codeunit.Run`, `XmlPort.Run`, and similar runtime invocations. Reserve numeric IDs for diagnostic tooling that genuinely needs them. + +See sample: `use-named-invocations-instead-of-object-ids.good.al`. + +## Anti Pattern + +`Page.RunModal(525, SalesShptLine);` — the reader has no idea what page 525 is without a lookup, and a future rename of page 525 or renumber of "Posted Sales Shipment Lines" produces a silent mismatch. + +See sample: `use-named-invocations-instead-of-object-ids.bad.al`. diff --git a/microsoft/knowledge/style/use-this-keyword-in-codeunits.bad.al b/microsoft/knowledge/style/use-this-keyword-in-codeunits.bad.al new file mode 100644 index 0000000..b35982d --- /dev/null +++ b/microsoft/knowledge/style/use-this-keyword-in-codeunits.bad.al @@ -0,0 +1,15 @@ +codeunit 51117 "Style Sample ThisKeyword Bad" +{ + procedure ProcessRecord(var Customer: Record Customer) + begin + // Ambiguous: is ValidateCustomer a local, a global, or a method on + // another codeunit in scope? + ValidateCustomer(Customer); + + // No way to pass the current codeunit without `this`. + end; + + local procedure ValidateCustomer(var Customer: Record Customer) + begin + end; +} diff --git a/microsoft/knowledge/style/use-this-keyword-in-codeunits.good.al b/microsoft/knowledge/style/use-this-keyword-in-codeunits.good.al new file mode 100644 index 0000000..6a6bf94 --- /dev/null +++ b/microsoft/knowledge/style/use-this-keyword-in-codeunits.good.al @@ -0,0 +1,21 @@ +codeunit 51116 "Style Sample ThisKeyword Good" +{ + procedure ProcessRecord(var Customer: Record Customer) + var + Other: Codeunit "Style Sample ThisKeyword Good"; + begin + // Clearly this codeunit's method. + this.ValidateCustomer(Customer); + + // Only way to pass the current codeunit as an argument. + Other.DoWith(this); + end; + + local procedure ValidateCustomer(var Customer: Record Customer) + begin + end; + + procedure DoWith(var Helper: Codeunit "Style Sample ThisKeyword Good") + begin + end; +} diff --git a/microsoft/knowledge/style/use-this-keyword-in-codeunits.md b/microsoft/knowledge/style/use-this-keyword-in-codeunits.md new file mode 100644 index 0000000..08a26f8 --- /dev/null +++ b/microsoft/knowledge/style/use-this-keyword-in-codeunits.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [this, codeunit, self-reference, aa0248, readability] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use the `this` keyword for codeunit self-reference + +## Description + +CodeCop rule AA0248 recommends the `this` keyword inside codeunit procedures when referring to the codeunit's own members or passing the codeunit itself to another procedure. AL's scope resolution otherwise blurs global-variable access, local-variable access, and same-codeunit method calls into the same unqualified syntax — a reader of `ValidateCustomer(Customer)` cannot tell at the call site whether `ValidateCustomer` is a local, a global, or a method on a different codeunit in scope. `this.ValidateCustomer(Customer)` removes the ambiguity, and `OtherCodeunit.DoWork(this)` is the only way to pass the current codeunit as a parameter. + +## Best Practice + +In codeunits, prefix same-codeunit method calls with `this.` when the call is ambiguous or when the scope spans more than a few lines. When the current codeunit needs to be passed as an argument, write `this` — there is no alternative syntax. The rule applies to codeunits; pages, reports, and tables have their own scoping. + +See sample: `use-this-keyword-in-codeunits.good.al`. + +## Anti Pattern + +`ValidateCustomer(Customer); SomeOtherCodeunit.DoWork(/* this codeunit? */);` — the first call has ambiguous origin, and the second cannot pass the current codeunit without `this`. The style becomes load-bearing as the codeunit grows past a few small procedures. + +See sample: `use-this-keyword-in-codeunits.bad.al`. diff --git a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.bad.al b/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.bad.al new file mode 100644 index 0000000..a519d8d --- /dev/null +++ b/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.bad.al @@ -0,0 +1,26 @@ +page 51005 "UI Sample ActionTooltip Bad" +{ + PageType = Card; + SourceTable = "Sales Header"; + + actions + { + area(Processing) + { + action(Post) + { + Caption = 'Post'; + ApplicationArea = All; + // Declarative, not imperative. No period. + ToolTip = 'This will post the invoice'; + } + action(SendForApproval) + { + Caption = 'Send for approval'; + ApplicationArea = All; + // Fragment that repeats the caption and says nothing new. + ToolTip = 'Send for approval'; + } + } + } +} diff --git a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.good.al b/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.good.al new file mode 100644 index 0000000..2dcab7e --- /dev/null +++ b/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.good.al @@ -0,0 +1,25 @@ +page 51004 "UI Sample ActionTooltip Good" +{ + PageType = Card; + SourceTable = "Sales Header"; + + actions + { + area(Processing) + { + action(Post) + { + Caption = 'Post'; + ApplicationArea = All; + // Imperative verb-first sentence, Sentence case, terminating period. + ToolTip = 'Post the current sales invoice and finalize the transaction.'; + } + action(SendForApproval) + { + Caption = 'Send for approval'; + ApplicationArea = All; + ToolTip = 'Send the document to the approval workflow.'; + } + } + } +} diff --git a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.md b/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.md new file mode 100644 index 0000000..bc2d457 --- /dev/null +++ b/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: ui +keywords: [tooltip, action, imperative, voice, period] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Action tooltips are imperative, verb-first sentences ending with a period + +## Description + +Action tooltips describe what the user will cause by invoking the action. The house style is an imperative verb-first sentence — `Post the current sales invoice and finalize the transaction.` — not a declarative one ("This will post …") and not a fragment ("Post invoice"). The imperative voice matches how the user reads the action bar: each tooltip completes the sentence "If I click this, the system will …" in the same grammatical form. Shortcut-key hints, when present, belong at the end of the tooltip and are retained verbatim. + +## Best Practice + +Start the tooltip with the verb. Use Sentence case, end with a period, stay within the ~250-character budget. Keep one sentence unless the action genuinely needs two; avoid editorializing ("Easily post …") or narrating ("This action posts …"). Preserve any existing shortcut annotation. + +See sample: `action-tooltips-are-imperative-and-end-with-period.good.al`. + +## Anti Pattern + +`ToolTip = 'This will post the invoice'` — declarative rather than imperative, no period. `ToolTip = 'Post'` — one-word fragment that duplicates the Caption and says nothing new. Both fail the scan-the-action-bar comprehension test. + +See sample: `action-tooltips-are-imperative-and-end-with-period.bad.al`. diff --git a/microsoft/knowledge/ui/avoid-banned-ui-terms.md b/microsoft/knowledge/ui/avoid-banned-ui-terms.md new file mode 100644 index 0000000..efb5c79 --- /dev/null +++ b/microsoft/knowledge/ui/avoid-banned-ui-terms.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [terminology, disabled, invalid, whitelist, blacklist, voice] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid banned UI terms; prefer the inclusive and direct replacements + +## Description + +Business Central's UI voice guidelines exclude four terms that carry connotations the product does not want to push onto users: "Disabled" (clinical/negative), "Invalid" (pejorative), "Whitelist" and "Blacklist" (terms with racial associations the industry has moved away from). The replacements read naturally, match the product's warm-and-direct voice, and align with Microsoft's cross-product terminology. The concern applies to user-visible text — captions, tooltips, error messages, notifications — not to variable names or code comments. + +## Best Practice + +Replace "Disabled" with "Turned off" or "Not available". Replace "Invalid" with "Not valid" or "Incorrect". Replace "Whitelist" with "Allow list". Replace "Blacklist" with "Block list". Apply the substitution in all UI text surfaces: Caption, ToolTip, AboutTitle, AboutText, Label values, Message/Confirm/Error strings. + +## Anti Pattern + +`ErrorLbl: Label 'Invalid input.'`, `Caption = 'Disabled Users'`, `ToolTip = 'Specifies the blacklist of blocked senders.'` — all three terms in places the user will read. The fix is literal substitution with the approved alternative. diff --git a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.bad.al b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.bad.al new file mode 100644 index 0000000..8c82cf1 --- /dev/null +++ b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.bad.al @@ -0,0 +1,21 @@ +page 51001 "UI Sample Caption Bad" +{ + PageType = List; + SourceTable = Customer; + + // Noun phrase in Sentence case. Every other list page in the product is Title Case. + Caption = 'Sales orders'; + + actions + { + area(Processing) + { + // Sentence phrase in Title Case. Reads as a typo. + action(PostAndPrint) + { + Caption = 'Post And Print'; + ApplicationArea = All; + } + } + } +} diff --git a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.good.al b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.good.al new file mode 100644 index 0000000..e3bec15 --- /dev/null +++ b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.good.al @@ -0,0 +1,27 @@ +page 51000 "UI Sample Caption Good" +{ + PageType = List; + SourceTable = Customer; + + // Noun-phrase page caption: Title Case. + Caption = 'Sales Orders'; + + actions + { + area(Processing) + { + // Sentence-phrase action caption: Sentence case. + action(PostAndPrint) + { + Caption = 'Post and print'; + ApplicationArea = All; + } + + action(SendEmail) + { + Caption = 'Send email'; + ApplicationArea = All; + } + } + } +} diff --git a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md new file mode 100644 index 0000000..595010f --- /dev/null +++ b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: ui +keywords: [caption, capitalization, title-case, sentence-case, noun-phrase] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Capitalize captions by phrase type: noun phrase is Title Case, sentence phrase is Sentence case + +## Description + +Business Central UI captions follow a simple capitalization rule that depends on the grammatical shape of the caption, not its location. A caption that is a pure noun phrase — no verb — uses Title Case: each major word capitalized (`Sales Orders`, `Chart of Accounts`, `Payment Terms`). A caption that is an imperative or declarative sentence phrase — contains a verb — uses Sentence case: only the first word and proper nouns capitalized (`Post and print`, `Send email`, `Create flow`). Following the rule makes unrelated captions feel consistent; ignoring it is visibly inconsistent in the user's navigation. + +## Best Practice + +Decide by parsing the caption as a phrase. "Sales Orders" is a thing; Title Case. "Post and print" tells the user to do something; Sentence case. For captions that are literally a single noun (`Save`, `Close`), treat them as sentence phrases — the imperative verb is implied. + +See sample: `caption-capitalization-noun-phrase-vs-sentence-phrase.good.al`. + +## Anti Pattern + +Writing `Caption = 'Sales orders'` on a list page (noun phrase styled as a sentence) or `Caption = 'Post And Print'` on an action (sentence phrase styled as title case). Both read as typos to a native English reader and inconsistent to a translator. + +See sample: `caption-capitalization-noun-phrase-vs-sentence-phrase.bad.al`. diff --git a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.bad.al b/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.bad.al new file mode 100644 index 0000000..3f7eec1 --- /dev/null +++ b/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.bad.al @@ -0,0 +1,26 @@ +page 51003 "UI Sample FieldTooltip Bad" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + group(General) + { + field("Name"; Rec.Name) + { + ApplicationArea = All; + // No "Specifies" opener, no period, a bare fragment. + ToolTip = 'The name of the customer'; + } + field("Balance (LCY)"; Rec."Balance (LCY)") + { + ApplicationArea = All; + ToolTip = 'Balance'; + } + } + } + } +} diff --git a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.good.al b/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.good.al new file mode 100644 index 0000000..100dfcf --- /dev/null +++ b/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.good.al @@ -0,0 +1,25 @@ +page 51002 "UI Sample FieldTooltip Good" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + group(General) + { + field("Name"; Rec.Name) + { + ApplicationArea = All; + ToolTip = 'Specifies the name of the customer.'; + } + field("Balance (LCY)"; Rec."Balance (LCY)") + { + ApplicationArea = All; + ToolTip = 'Shows the current balance in the local currency.'; + } + } + } + } +} diff --git a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md b/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md new file mode 100644 index 0000000..e00842c --- /dev/null +++ b/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: ui +keywords: [tooltip, field, specifies, voice, period] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Field tooltips start with "Specifies" and end with a period + +## Description + +Field tooltips describe what a value means, and the Business Central house style for them is a declarative sentence that starts with "Specifies" and ends with a period. The convention is not cosmetic: it yields a consistent voice across thousands of fields so a user scanning several tooltips in quick succession can compare them without re-parsing each opening clause. Alternative phrasings ("Shows …", "The …") are accepted when they describe the field clearly, but "Specifies …" is the default and the easiest to translate consistently. + +## Best Practice + +Write field tooltips as `Specifies .` — a single sentence, Sentence case, terminating period. Keep under the ~250-character tooltip budget (see `respect-ui-text-character-limits`). When the field's meaning is genuinely not a "specifies" sentence, use "Shows …" or a clearly descriptive alternative; avoid bare fragments. + +See sample: `field-tooltips-start-with-specifies-and-end-with-period.good.al`. + +## Anti Pattern + +`ToolTip = 'The name of the customer'` — missing "Specifies" opener, missing period. `ToolTip = 'Customer name'` — a fragment rather than a sentence. Both sit inconsistently next to adjacent "Specifies …" tooltips on the same page. + +See sample: `field-tooltips-start-with-specifies-and-end-with-period.bad.al`. diff --git a/microsoft/knowledge/ui/respect-ui-text-character-limits.md b/microsoft/knowledge/ui/respect-ui-text-character-limits.md new file mode 100644 index 0000000..7577656 --- /dev/null +++ b/microsoft/knowledge/ui/respect-ui-text-character-limits.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [caption, tooltip, character-limit, truncation, localization] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Respect Business Central's UI text character limits to avoid truncation + +## Description + +Business Central UI surfaces have practical character limits before the platform truncates or the translator's localization overflows the available space. Authoring captions and tooltips close to the English limit almost guarantees truncation in languages whose translations are longer (German, French, Spanish average 20–40% longer than English). The limits are not hard compiler errors — they are product-quality thresholds that agents should flag at author time so the string reaches localization with room to grow. + +## Best Practice + +Author within these approximate limits (English): action and field captions ~40 chars; field-group, menu-item, page, and dialog titles ~40 chars; button captions ~20 chars; action and field tooltips ~250 chars; dialog text and error messages ~250 chars; notifications ~100 chars; checklist ShortTitleChecklist 34, LongerTitleCard 53, CardDescription 180. Leave headroom for longer translations; at 40/40 in English, German is likely to truncate. + +## Anti Pattern + +`action(RecalculateAndReapplyAllOutstandingCustomerDiscounts) { Caption = 'Recalculate and reapply all outstanding customer discounts'; }` — 58 characters in English, essentially guaranteed to truncate once translated. The fix is to shorten the English caption (`Recalculate customer discounts`, 30 chars) and move the full sentence into the tooltip where the budget is larger. diff --git a/microsoft/knowledge/ui/titles-have-no-trailing-punctuation.md b/microsoft/knowledge/ui/titles-have-no-trailing-punctuation.md new file mode 100644 index 0000000..a076a76 --- /dev/null +++ b/microsoft/knowledge/ui/titles-have-no-trailing-punctuation.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [title, caption, page, dialog, punctuation, ellipsis] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Titles carry no trailing punctuation and no trailing ellipsis + +## Description + +Page titles, section titles, FastTab titles, and dialog titles in Business Central are labels, not sentences — they have no trailing period, question mark, or exclamation. Trailing ellipsis ("…" or "...") on a title is specifically a long-standing Windows convention for action buttons that open a dialog, and AL handles that via the action's runtime behaviour rather than the caption text. Adding the ellipsis literally into a page caption or action caption is wrong in both directions: the platform also displays its own ellipsis when appropriate, and the static three dots corrupt translations that adjust punctuation for the locale. + +## Best Practice + +End titles with the last word of the title. Sentence case per the capitalization rule for the phrase type (see `caption-capitalization-noun-phrase-vs-sentence-phrase`). If a dialog needs "…" behaviour, rely on the platform; do not type the characters into the caption string. + +## Anti Pattern + +`Caption = 'Setup wizard...'`, `Caption = 'Sales orders.'`, `page Caption = 'Customer list:'` — all three decorate the title with terminal punctuation that is noise to the reader and a translation headache. diff --git a/microsoft/knowledge/ui/tooltips-describe-teaching-tips-guide.md b/microsoft/knowledge/ui/tooltips-describe-teaching-tips-guide.md new file mode 100644 index 0000000..da920e3 --- /dev/null +++ b/microsoft/knowledge/ui/tooltips-describe-teaching-tips-guide.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [tooltip, teaching-tip, abouttitle, abouttext, onboarding] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Tooltips describe what a thing is; teaching tips guide what the user can do with it + +## Description + +Business Central exposes two distinct affordances for explaining the UI: ToolTip and the AboutTitle/AboutText teaching tip. They answer different questions and are complementary, not alternatives. ToolTip answers "What is this field/action?" and is expected on every field and action. The teaching tip answers "What can I do with this page or this important element?" and is reserved for the few entry points where an onboarding hint is worth the user's attention. Authors who put teaching-tip content in tooltips make tooltips noisy; authors who put tooltip content in teaching tips make teaching tips useless. + +## Best Practice + +Write ToolTip as a concise descriptive sentence following the "Specifies …" or imperative voice rules. Reserve AboutTitle/AboutText for the top-level card and list pages where first-time users benefit from discovering the page's purpose and outcome. On list pages, title uses the plural form ("About sales invoices"). On card or document pages, title uses the entity name plus "details" ("About sales invoice details"). + +## Anti Pattern + +A field ToolTip that tells the user "You can create new customers from here and update their payment terms, and the list also shows…" — that is teaching-tip content. Conversely, an AboutText that simply repeats the page Caption tells the user nothing they did not already read in the title bar. diff --git a/microsoft/knowledge/ui/tour-tips-do-not-use-action-language.md b/microsoft/knowledge/ui/tour-tips-do-not-use-action-language.md new file mode 100644 index 0000000..026a005 --- /dev/null +++ b/microsoft/knowledge/ui/tour-tips-do-not-use-action-language.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [tour-tip, abouttext, teaching-tip, imperative, onboarding] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Tour tips describe outcomes, not instructions — never tell the user to perform an action during the tour + +## Description + +A tour is a guided sequence of teaching tips that runs over the page while the user is passively watching. The tour framework does not expose the page's actions during the tip — so an `AboutText` that tells the user `Enter the customer name here.` or `Now post the invoice.` asks the user to do something that is not possible in the moment. The result is a confusing first-run experience. Tour content should describe what the element represents and what the user will be able to do with it after the tour completes, in descriptive rather than imperative voice. + +## Best Practice + +Write tour AboutTitle as a short noun-phrase label for the element ("Who you are selling to", "When all is set, you post"). Write AboutText as one or two sentences that describe the outcome or meaning, not steps. Keep the tour itself short — one to four tips total — and let the regular ToolTip carry the per-element detail. + +## Anti Pattern + +`AboutText = 'Enter the customer name here.'` on a tour tip — the action is not active. `AboutText = 'Now post the invoice.'` during a tour — the user cannot, and would not want to mid-tour. Both teach nothing and confuse the reader. diff --git a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.bad.al b/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.bad.al new file mode 100644 index 0000000..8142b2f --- /dev/null +++ b/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.bad.al @@ -0,0 +1,20 @@ +page 51007 "UI Sample Ampersand Bad" +{ + PageType = Card; + SourceTable = "Sales Header"; + + actions + { + area(Processing) + { + action(PostAndSend) + { + // '&' is being used as "and", not as an accelerator prefix. The + // parser cannot tell; translators re-evaluate every occurrence. + Caption = 'Post & Send'; + ApplicationArea = All; + ToolTip = 'Post and send the document.'; + } + } + } +} diff --git a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.good.al b/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.good.al new file mode 100644 index 0000000..723a70b --- /dev/null +++ b/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.good.al @@ -0,0 +1,19 @@ +page 51006 "UI Sample Ampersand Good" +{ + PageType = Card; + SourceTable = "Sales Header"; + + actions + { + area(Processing) + { + action(PostAndSend) + { + // "and" written out. Ampersand-s marks 's' as the accelerator key. + Caption = 'Post and &send'; + ApplicationArea = All; + ToolTip = 'Post the document and send it to the customer.'; + } + } + } +} diff --git a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.md b/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.md new file mode 100644 index 0000000..02746fd --- /dev/null +++ b/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: ui +keywords: [ampersand, caption, accelerator, translation, voice] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Write "and" in UI captions; keep the ampersand only as an accelerator-key prefix + +## Description + +AL Caption strings use the ampersand character in two distinct ways. Inside a caption, `&` is the accelerator-key prefix — `Caption = '&Post'` underlines the P and makes Alt+P activate the action. Outside that role, `&` is sometimes used as a shortening for the word "and" (`Post & Send`). The first usage is platform-defined and must be preserved. The second is a style choice that the Business Central voice guidelines reject: `Post and send` reads naturally in all supported locales and translates cleanly, while `Post & Send` conveys nothing extra and adds a character that localizers have to re-evaluate. + +## Best Practice + +Use the word "and" in caption text. Keep `&` only when it is immediately followed by a letter chosen as the keyboard accelerator. If both meanings apply, write them explicitly: `Post and &send` uses `s` as the accelerator and spells the conjunction out. + +See sample: `use-and-not-ampersand-in-ui-captions.good.al`. + +## Anti Pattern + +`Caption = 'Post & Send'` as the full caption — the ampersand is meant as "and" but the AL parser cannot tell, and the result is inconsistent with every other "X and Y" caption in the product. + +See sample: `use-and-not-ampersand-in-ui-captions.bad.al`. diff --git a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.bad.al b/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.bad.al new file mode 100644 index 0000000..1d83a48 --- /dev/null +++ b/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.bad.al @@ -0,0 +1,14 @@ +codeunit 50801 "Upgrade Sample CallMethods Bad" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + Customer: Record Customer; + begin + // Inline logic in the trigger body: no tag guard, not testable in isolation, + // re-runs on every upgrade. + Customer.SetRange(Blocked, Customer.Blocked::" "); + Customer.ModifyAll("Some Field", true); + end; +} diff --git a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.good.al b/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.good.al new file mode 100644 index 0000000..e5c7218 --- /dev/null +++ b/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.good.al @@ -0,0 +1,31 @@ +codeunit 50800 "Upgrade Sample CallMethods Good" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeCustomerDefaults(); + UpgradeSalesDocumentDefaults(); + end; + + local procedure UpgradeCustomerDefaults() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(CustomerDefaultsUpgradeTag()) then + exit; + + // Step body omitted + + UpgradeTag.SetUpgradeTag(CustomerDefaultsUpgradeTag()); + end; + + local procedure UpgradeSalesDocumentDefaults() + begin + end; + + local procedure CustomerDefaultsUpgradeTag(): Code[250] + begin + exit('MS-000001-CustomerDefaults-20260501'); + end; +} diff --git a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.md b/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.md new file mode 100644 index 0000000..cf04b13 --- /dev/null +++ b/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [upgrade-codeunit, onupgradepercompany, onupgradeperdatabase, structure] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Call named methods from OnUpgrade triggers; keep the triggers empty of logic + +## Description + +An upgrade codeunit (`Subtype = Upgrade`) runs its triggers once per upgrade scope. Inlining upgrade logic inside the trigger body mixes the entry point with the work, makes individual steps untestable in isolation, and prevents the standard upgrade-tag guard pattern from being applied cleanly. The convention across Business Central's own upgrade codeunits is that `OnUpgradePerCompany` and `OnUpgradePerDatabase` are a list of calls to named local procedures, each implementing one step behind its own upgrade-tag check. + +## Best Practice + +Keep `OnUpgradePerCompany` and `OnUpgradePerDatabase` to a list of `UpgradeXxx();` statements. Put every data migration, default, or correction in a named local procedure whose first action is the upgrade-tag guard. Empty trigger bodies are also acceptable as placeholders on a new codeunit with no current steps. + +See sample: `call-methods-from-onupgrade-triggers-not-inline-code.good.al`. + +## Anti Pattern + +Writing `Customer.ModifyAll(...)`, `TableX.SetRange(...)` + loops, or `DataTransfer.CopyFields()` directly inside the trigger body. The step is untagged, untestable, and re-runs on every upgrade. + +See sample: `call-methods-from-onupgrade-triggers-not-inline-code.bad.al`. diff --git a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.bad.al b/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.bad.al new file mode 100644 index 0000000..e895201 --- /dev/null +++ b/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.bad.al @@ -0,0 +1,15 @@ +codeunit 50822 "Upgrade Sample FirstInstall Bad" +{ + Subtype = Install; + + trigger OnInstallAppPerCompany() + begin + // Unconditional initialization. Re-install after uninstall either throws + // on primary-key collisions or overwrites existing rows. + InsertDefaultSetup(); + end; + + local procedure InsertDefaultSetup() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.good.al b/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.good.al new file mode 100644 index 0000000..174e2d9 --- /dev/null +++ b/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.good.al @@ -0,0 +1,20 @@ +codeunit 50821 "Upgrade Sample FirstInstall Good" +{ + Subtype = Install; + + trigger OnInstallAppPerCompany() + var + AppInfo: ModuleInfo; + begin + NavApp.GetCurrentModuleInfo(AppInfo); + if AppInfo.DataVersion() <> Version.Create('0.0.0.0') then + exit; + + // First-install-only initialization follows here. + InsertDefaultSetup(); + end; + + local procedure InsertDefaultSetup() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.md b/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.md new file mode 100644 index 0000000..84d72fb --- /dev/null +++ b/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [oninstall, dataversion, appinfo, first-install, upgrade-tag] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Detect first install via DataVersion equal to 0.0.0.0 in OnInstall triggers + +## Description + +`OnInstallAppPerCompany` fires on first install and on subsequent re-installs after an uninstall. Code that should only run on the very first install needs to distinguish the two — and the supported way is checking `AppInfo.DataVersion() = Version.Create('0.0.0.0')`, which is the sentinel for "no prior data exists for this app in this tenant". This is the one case where a DataVersion check is correct; steady-state upgrade steps should use upgrade tags instead. + +## Best Practice + +In `OnInstallAppPerCompany`, call `NavApp.GetCurrentModuleInfo(AppInfo)` and exit early when `AppInfo.DataVersion()` is non-zero. The remainder of the trigger body then runs exclusively on first install. For all other version-sensitive upgrade logic, use upgrade tags (see `use-upgrade-tags-not-version-checks`). + +See sample: `detect-first-install-via-dataversion-zero.good.al`. + +## Anti Pattern + +Running initialization unconditionally in `OnInstallAppPerCompany` and relying on primary-key collisions to avoid double-inserts. Re-install scenarios either throw or overwrite existing rows; the install path becomes brittle as the app grows. + +See sample: `detect-first-install-via-dataversion-zero.bad.al`. diff --git a/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md b/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md new file mode 100644 index 0000000..57bdde3 --- /dev/null +++ b/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [upgrade, httpclient, external-service, dotnet, availability] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not make external service calls inside upgrade codeunits + +## Description + +The upgrade scope has to complete for the tenant to reach the new version. Any call in the upgrade path that depends on an external service — HttpClient to a partner API, a DotNet interop call, a codeunit that fetches remote configuration — fails closed when the service is unreachable, misconfigured, or slow. The failure blocks the upgrade for every customer whose environment cannot reach the dependency at the moment the upgrade runs, and there is no user present to retry. The scope is specifically code inside codeunits with `Subtype = Upgrade` or reachable from their triggers. + +## Best Practice + +Defer external calls to runtime code that executes after the upgrade — install-triggered tasks, background job queue entries scheduled by the upgrade, or lazy initialization on first use. The upgrade step should compute a local result or mark work to be done, not perform the remote call itself. + +## Anti Pattern + +`HttpClient.Get(...)` or `DotNetType.CallStaticMethod(...)` directly in `OnUpgradePerCompany`, or in a local procedure called from it. The upgrade now depends on network availability to a service the platform does not control. diff --git a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.bad.al b/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.bad.al new file mode 100644 index 0000000..0eb1590 --- /dev/null +++ b/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.bad.al @@ -0,0 +1,23 @@ +enum 50815 "Upgrade Sample EnumInsert Bad" +{ + Extensible = true; + + value(0; First) { Caption = 'First'; } + + // Inserting at ordinal 1 shifts everything below. Every row that stored + // ordinal 1 before now resolves to NewMiddleValue. + value(1; NewMiddleValue) { Caption = 'New middle value'; } + + value(2; Second) { Caption = 'Second'; } + value(3; Third) { Caption = 'Third'; } +} + +enum 50816 "Upgrade Sample EnumRemove Bad" +{ + Extensible = true; + + value(0; First) { Caption = 'First'; } + // value(1; Second) removed without obsoletion. + // Existing rows storing ordinal 1 no longer resolve to any declared value. + value(2; Third) { Caption = 'Third'; } +} diff --git a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.good.al b/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.good.al new file mode 100644 index 0000000..073a9c6 --- /dev/null +++ b/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.good.al @@ -0,0 +1,29 @@ +enum 50813 "Upgrade Sample EnumAdditive Good" +{ + Extensible = true; + + value(0; First) { Caption = 'First'; } + value(1; Second) { Caption = 'Second'; } + value(2; Third) { Caption = 'Third'; } + + // New value appended at the next free ordinal. Existing stored ordinals + // (0, 1, 2) keep their meaning. + value(3; NewValue) { Caption = 'New value'; } +} + +enum 50814 "Upgrade Sample EnumRetire Good" +{ + Extensible = true; + + value(0; First) { Caption = 'First'; } + + value(1; Second) + { + Caption = 'Second'; + ObsoleteState = Removed; + ObsoleteReason = 'Replaced by NewValue.'; + ObsoleteTag = '28.0'; + } + + value(2; Third) { Caption = 'Third'; } +} diff --git a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md b/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md new file mode 100644 index 0000000..1dcba69 --- /dev/null +++ b/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [enum, ordinal, obsolete, backward-compatibility, breaking-change] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Enum changes must be additive at the end; never insert or remove values + +## Description + +AL enums store their ordinal on disk. Inserting a new value in the middle of an existing enum shifts every following ordinal by one: every row whose field holds the old ordinal N now resolves to the value that used to be N+1. Removing a value without obsoletion has the same effect. Both changes are data corruption disguised as a code edit and are effectively irreversible once a tenant has upgraded. Adding values at the end is safe — existing ordinals keep their meaning. + +## Best Practice + +Append new enum values at the end, taking the next free ordinal. When a value must be retired, mark it with `ObsoleteState = Removed`, `ObsoleteReason`, and `ObsoleteTag` so tooling and downstream code can detect the deprecation; do not reclaim the ordinal. Renaming the caption on an existing ordinal is fine. + +See sample: `enum-changes-must-be-additive-at-the-end.good.al`. + +## Anti Pattern + +Inserting `value(1; "NewMiddleValue")` between existing `value(0; "First")` and the original `value(1; "Second")`. Every row that stored ordinal 1 before the change now reads as `NewMiddleValue`. The same applies to removing a value outright without obsoletion. + +See sample: `enum-changes-must-be-additive-at-the-end.bad.al`. diff --git a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.bad.al b/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.bad.al new file mode 100644 index 0000000..2003dab --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.bad.al @@ -0,0 +1,19 @@ +codeunit 50807 "Upgrade Sample GuardReads Bad" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + Setup: Record "Sales & Receivables Setup"; + Customer: Record Customer; + begin + // Unguarded Get. One tenant whose Setup row is missing blocks the upgrade. + Setup.Get(); + + // Unguarded FindSet. Raises when the table is empty for this tenant. + Customer.FindSet(); + repeat + // per-row work + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.good.al b/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.good.al new file mode 100644 index 0000000..f98f45d --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.good.al @@ -0,0 +1,23 @@ +codeunit 50806 "Upgrade Sample GuardReads Good" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeDefaults(); + end; + + local procedure UpgradeDefaults() + var + Setup: Record "Sales & Receivables Setup"; + Customer: Record Customer; + begin + if not Setup.Get() then + exit; + + if Customer.FindSet() then + repeat + // per-row work + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.md b/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.md new file mode 100644 index 0000000..f3c6f99 --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [upgrade, get, findset, findlast, guard, unblocking] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Guard every database read in upgrade codeunits; never let a missing row block the upgrade + +## Description + +An unguarded `Record.Get()` raises when the row does not exist; an unguarded `FindSet()` or `FindLast()` raises when the result set is empty. In ordinary runtime code those errors surface to a user who can retry. In an upgrade codeunit they abort the upgrade of the tenant and the customer is blocked from getting to the new version. Real-world data is inconsistent enough — missing lookup rows, empty setup tables, skipped modules — that an unguarded read reliably blocks at least one customer per release. + +## Best Practice + +Wrap every Get, FindSet, FindFirst, FindLast, and related call in an `if … then` guard. On the not-found path, either exit the current step or log telemetry and continue; never let the upgrade scope raise. `if Customer.FindSet() then;` (statement terminator as the entire body) is an acceptable pattern when only the side effect of positioning matters. + +See sample: `guard-every-database-read-in-upgrade-codeunits.good.al`. + +## Anti Pattern + +`Customer.Get(CustomerNo);` or `SalesHeader.FindLast();` inside an upgrade procedure. One missing row in one tenant turns every future upgrade into a support ticket. + +See sample: `guard-every-database-read-in-upgrade-codeunits.bad.al`. diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.bad.al b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.bad.al new file mode 100644 index 0000000..dd54bbd --- /dev/null +++ b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.bad.al @@ -0,0 +1,14 @@ +tableextension 50812 "Upgrade Sample InitValue Bad" extends Customer +{ + fields + { + field(50101; "Is Active"; Boolean) + { + DataClassification = CustomerContent; + Caption = 'Is active'; + // InitValue applies to new records only. + // Every existing customer remains Is Active = false after the upgrade. + InitValue = true; + } + } +} diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.good.al b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.good.al new file mode 100644 index 0000000..e26cfa3 --- /dev/null +++ b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.good.al @@ -0,0 +1,43 @@ +tableextension 50810 "Upgrade Sample InitValue Good" extends Customer +{ + fields + { + field(50100; "Is Active"; Boolean) + { + DataClassification = CustomerContent; + Caption = 'Is active'; + InitValue = true; + } + } +} + +codeunit 50811 "Upgrade Sample InitValue Good Upg" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeExistingCustomersIsActive(); + end; + + local procedure UpgradeExistingCustomersIsActive() + var + Customer: Record Customer; + CustomerDataTransfer: DataTransfer; + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(UpgradeCustomerIsActiveTag()) then + exit; + + CustomerDataTransfer.SetTables(Database::Customer, Database::Customer); + CustomerDataTransfer.AddConstantValue(true, Customer.FieldNo("Is Active")); + CustomerDataTransfer.CopyFields(); + + UpgradeTag.SetUpgradeTag(UpgradeCustomerIsActiveTag()); + end; + + local procedure UpgradeCustomerIsActiveTag(): Code[250] + begin + exit('MS-000006-CustomerIsActive-20260501'); + end; +} diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md new file mode 100644 index 0000000..6f0b612 --- /dev/null +++ b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [initvalue, field, upgrade, existing-records, migration] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# InitValue on a new field does not populate existing rows + +## Description + +The `InitValue` property sets a field's default for rows created after the field exists. Rows that already exist when the field is added keep the data-type default (empty text, zero, false, epoch date) — InitValue does not retroactively apply. Shipping a new field with `InitValue = true` on an existing table produces a silently inconsistent dataset: new rows match the intended default, existing rows do not, and callers that do not distinguish the two read the wrong state for existing data. + +## Best Practice + +When adding a field to an existing table with a meaningful default, write an upgrade step that populates existing rows with the same value, guarded by its own upgrade tag. Use `DataTransfer` with `AddConstantValue` for set-based initialization (see `use-datatransfer-for-large-dataset-initialization`). Exceptions: brand-new tables, new Boolean fields where `false` is the correct value for existing rows, and informational fields where empty is an acceptable state. + +See sample: `initvalue-does-not-populate-existing-records.good.al`. + +## Anti Pattern + +Adding `field(100; "Is Active"; Boolean) { InitValue = true; }` to an existing business table without upgrade code. New records are Active; every existing record is silently inactive. The bug surfaces later as "why is this data missing from the default report?" + +See sample: `initvalue-does-not-populate-existing-records.bad.al`. diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al new file mode 100644 index 0000000..d2ebe49 --- /dev/null +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al @@ -0,0 +1,22 @@ +codeunit 50805 "Upgrade Sample TagRegister Bad" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then + exit; + + UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag()); + end; + + // Missing OnGetPerCompanyUpgradeTags subscriber. + // The tag is set but the platform's upgrade-tag machinery does not know about it. + + local procedure FeatureXUpgradeTag(): Code[250] + begin + exit('MS-000004-FeatureX-20260501'); + end; +} diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al new file mode 100644 index 0000000..0fb5118 --- /dev/null +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al @@ -0,0 +1,25 @@ +codeunit 50804 "Upgrade Sample TagRegister Good" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then + exit; + + UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag()); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Upgrade Tag", 'OnGetPerCompanyUpgradeTags', '', false, false)] + local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]]) + begin + PerCompanyUpgradeTags.Add(FeatureXUpgradeTag()); + end; + + local procedure FeatureXUpgradeTag(): Code[250] + begin + exit('MS-000003-FeatureX-20260501'); + end; +} diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md new file mode 100644 index 0000000..fd260dd --- /dev/null +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [upgrade-tag, ongetpercompanyupgradetags, ongetperdatabaseupgradetags, registration] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Register every upgrade tag with the matching PerCompany or PerDatabase subscriber + +## Description + +An upgrade tag set via `UpgradeTag.SetUpgradeTag` only participates in the platform's upgrade-tag machinery when it is also registered through `OnGetPerCompanyUpgradeTags` or `OnGetPerDatabaseUpgradeTags` event subscribers on `Codeunit "Upgrade Tag"`. Without registration, the platform cannot enumerate the tag for diagnostic reporting, skipped-step detection, or cross-app coordination. The step still runs and sets the tag, but the tag is effectively invisible to the rest of the upgrade infrastructure. + +## Best Practice + +For every upgrade-tag constant referenced in `HasUpgradeTag`/`SetUpgradeTag`, register it in the subscriber that matches its trigger scope: tags used from `OnUpgradePerCompany` go in `OnGetPerCompanyUpgradeTags`; tags used from `OnUpgradePerDatabase` go in `OnGetPerDatabaseUpgradeTags`. Keep the tag string in a single source (Label or function) and reference it at the guard, the setter, and the registration. + +See sample: `register-upgrade-tags-with-subscribers.good.al`. + +## Anti Pattern + +Adding a new `UpgradeTag.SetUpgradeTag(MyTag())` without the matching `PerCompanyUpgradeTags.Add(MyTag())` in the registration subscriber. The code compiles and the step completes, but the tag is unregistered and the infrastructure is partially disabled. + +See sample: `register-upgrade-tags-with-subscribers.bad.al`. diff --git a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.bad.al b/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.bad.al new file mode 100644 index 0000000..300b5d7 --- /dev/null +++ b/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.bad.al @@ -0,0 +1,14 @@ +codeunit 50820 "Upgrade Sample SkipContext Bad" +{ + procedure AddReportSelectionEntries() + begin + // No execution-context check. On upgrade, this either throws on + // primary-key conflict or silently overwrites the tenant's + // customized report selections. + InsertDefaultReportSelections(); + end; + + local procedure InsertDefaultReportSelections() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.good.al b/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.good.al new file mode 100644 index 0000000..04ade74 --- /dev/null +++ b/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.good.al @@ -0,0 +1,15 @@ +codeunit 50819 "Upgrade Sample SkipContext Good" +{ + procedure AddReportSelectionEntries() + begin + // Existing tenants already have the selections, possibly customized. + if GetExecutionContext() = ExecutionContext::Upgrade then + exit; + + InsertDefaultReportSelections(); + end; + + local procedure InsertDefaultReportSelections() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.md b/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.md new file mode 100644 index 0000000..39a53df --- /dev/null +++ b/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [executioncontext, upgrade, reportselections, initialization, install] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Skip non-essential initialization when ExecutionContext is Upgrade + +## Description + +Initialization code that inserts default rows — report selections, number-series, setup-table defaults — is correct on first install and harmful during upgrade. Existing tenants already have these rows, possibly customized; re-running the initialization either fails on primary-key conflicts or silently overwrites customer configuration. The platform exposes `GetExecutionContext()` so the same procedure can be safely called from install and upgrade paths without duplicating the insert logic. + +## Best Practice + +Check `if GetExecutionContext() = ExecutionContext::Upgrade then exit;` at the top of idempotent-on-install-only procedures. Keep the early exit narrow and document the reason. The check should be additive to existing guards, not a replacement for proper primary-key handling in the insert itself. + +See sample: `skip-non-essential-work-during-upgrade-context.good.al`. + +## Anti Pattern + +A procedure that unconditionally inserts a default report-selection, number-series, or setup row, called from both install and upgrade paths. On upgrade it either throws on the conflicting key or overwrites the tenant's existing configuration. + +See sample: `skip-non-essential-work-during-upgrade-context.bad.al`. diff --git a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.bad.al b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.bad.al new file mode 100644 index 0000000..c232adb --- /dev/null +++ b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.bad.al @@ -0,0 +1,22 @@ +codeunit 50809 "Upgrade Sample DataTransfer Bad" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + InitializeNewFlag(); + end; + + local procedure InitializeNewFlag() + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + // Row-at-a-time update over a 10M-row ledger table. Multi-hour upgrade. + CustLedgerEntry.SetRange(Open, true); + if CustLedgerEntry.FindSet(true) then + repeat + CustLedgerEntry."New Flag" := false; + CustLedgerEntry.Modify(); + until CustLedgerEntry.Next() = 0; + end; +} diff --git a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.good.al b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.good.al new file mode 100644 index 0000000..b45a9b9 --- /dev/null +++ b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.good.al @@ -0,0 +1,31 @@ +codeunit 50808 "Upgrade Sample DataTransfer Good" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + InitializeNewFlag(); + end; + + local procedure InitializeNewFlag() + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + CLEDataTransfer: DataTransfer; + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(InitializeNewFlagTag()) then + exit; + + CLEDataTransfer.SetTables(Database::"Cust. Ledger Entry", Database::"Cust. Ledger Entry"); + CLEDataTransfer.AddSourceFilter(CustLedgerEntry.FieldNo(Open), '=%1', true); + CLEDataTransfer.AddConstantValue(false, CustLedgerEntry.FieldNo("New Flag")); + CLEDataTransfer.CopyFields(); + + UpgradeTag.SetUpgradeTag(InitializeNewFlagTag()); + end; + + local procedure InitializeNewFlagTag(): Code[250] + begin + exit('MS-000005-CLEInitializeNewFlag-20260501'); + end; +} diff --git a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md new file mode 100644 index 0000000..7a9afc9 --- /dev/null +++ b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [datatransfer, initvalue, large-dataset, bulk-update, upgrade] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use DataTransfer to initialize large tables in upgrade; not FindSet plus Modify + +## Description + +An upgrade that populates a new field on millions of existing rows with a FindSet+Modify loop pays a round-trip and a per-row trigger invocation for every row — turning a multi-hour upgrade into a multi-day one on ledger-entry-scale tables. `DataTransfer` pushes the update to SQL as a single set-based operation using source filters and constant values, which is the supported platform mechanism for this scenario. The tradeoff: DataTransfer bypasses validation triggers and event subscribers — if the step depends on trigger logic, that has to be reconstructed explicitly. + +## Best Practice + +Use DataTransfer for field-default initialization on existing tables, especially when the target is a ledger-entry or document-line table. Set tables, add source filters, add constant values, call CopyFields, clear, and repeat for additional slices. When trigger or subscriber behaviour is required, do that work separately against a filtered result set so the bulk update remains set-based. + +See sample: `use-datatransfer-for-large-dataset-initialization.good.al`. + +## Anti Pattern + +`FindSet(true)` + `Modify()` in a loop as the initialization path for a new field across an entire existing table. The resulting upgrade time is proportional to the row count; for a ten-million-row ledger-entry table it is the single largest step in the release. + +See sample: `use-datatransfer-for-large-dataset-initialization.bad.al`. diff --git a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.bad.al b/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.bad.al new file mode 100644 index 0000000..3ec4385 --- /dev/null +++ b/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.bad.al @@ -0,0 +1,11 @@ +codeunit 50818 "Upgrade Sample Obsolete Bad" +{ + // Straight to Removed with no preceding Pending phase, no ObsoleteReason, + // no ObsoleteTag. Dependents compiled against the previous release hit + // a hard compile error with no migration signal. + [Obsolete('', '')] + procedure CalculateNetAmount(Amount: Decimal): Decimal + begin + Error('Removed.'); + end; +} diff --git a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.good.al b/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.good.al new file mode 100644 index 0000000..2dc1f9c --- /dev/null +++ b/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.good.al @@ -0,0 +1,13 @@ +codeunit 50817 "Upgrade Sample Obsolete Good" +{ + [Obsolete('Use CalculateNetAmountV2 for the updated rounding semantics.', '28.0')] + procedure CalculateNetAmount(Amount: Decimal): Decimal + begin + exit(Amount); + end; + + procedure CalculateNetAmountV2(Amount: Decimal): Decimal + begin + exit(Amount); + end; +} diff --git a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.md b/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.md new file mode 100644 index 0000000..0c5a4e8 --- /dev/null +++ b/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [obsolete, obsoletestate, obsoletereason, obsoletetag, deprecation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Deprecate via ObsoleteState Pending first; move to Removed only after the grace window + +## Description + +AL's obsolete workflow is two-stage by design. `ObsoleteState = Pending` keeps the object or member compilable and callable but emits warnings and records the deprecation in metadata. `ObsoleteState = Removed` makes it a compile error for callers. Jumping straight to Removed — or marking Pending without `ObsoleteReason` and `ObsoleteTag` — breaks dependents who had no signal to migrate, and loses the tooling's ability to surface the planned removal in sandbox builds before the production tenant upgrades. + +## Best Practice + +Mark the element `ObsoleteState = Pending` with a concrete `ObsoleteReason` naming the replacement and an `ObsoleteTag` identifying the version the deprecation started. Keep it Pending through at least one major release so dependents have a cycle to migrate. Move to `ObsoleteState = Removed` only in a later release, with the same Reason and Tag retained or updated. + +See sample: `use-obsolete-pending-before-removed.good.al`. + +## Anti Pattern + +`[Obsolete('', '')]` or `ObsoleteState = Removed` applied directly on an element that was public and callable in the previous release, with no preceding Pending phase. Dependents get a hard compile error with no migration signal in the previous version. + +See sample: `use-obsolete-pending-before-removed.bad.al`. diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al new file mode 100644 index 0000000..bccfb66 --- /dev/null +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al @@ -0,0 +1,27 @@ +codeunit 50803 "Upgrade Sample TagGuard Bad" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + AppInfo: ModuleInfo; + begin + NavApp.GetCurrentModuleInfo(AppInfo); + + // Version check: fragile across skipped versions, and every nested branch + // is another place a customer can be stuck if the matching step fails. + if AppInfo.DataVersion().Major < 18 then + UpgradeFeatureA() + else + if AppInfo.DataVersion().Major < 21 then + UpgradeFeatureB(); + end; + + local procedure UpgradeFeatureA() + begin + end; + + local procedure UpgradeFeatureB() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al new file mode 100644 index 0000000..4c90219 --- /dev/null +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al @@ -0,0 +1,26 @@ +codeunit 50802 "Upgrade Sample TagGuard Good" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeFeatureX(); + end; + + local procedure UpgradeFeatureX() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then + exit; + + // Idempotent, retries cleanly after failure, runs exactly once. + + UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag()); + end; + + local procedure FeatureXUpgradeTag(): Code[250] + begin + exit('MS-000002-FeatureX-20260501'); + end; +} diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md new file mode 100644 index 0000000..68f81c0 --- /dev/null +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [upgrade-tag, dataversion, version-check, idempotent, guard] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Guard upgrade steps with upgrade tags, not version checks + +## Description + +`DataVersion()` comparisons tie an upgrade step to a specific release cadence: if the step is skipped or fails on one version and the tenant upgrades past the check before the step succeeds, the step never runs. Upgrade tags, managed by `Codeunit "Upgrade Tag"`, record per-step completion in the tenant database. A tag-guarded step runs once, retries cleanly after failure, and remains idempotent across future versions regardless of the version the customer is upgrading from. + +## Best Practice + +Guard each step with `if UpgradeTag.HasUpgradeTag(MyTag()) then exit;` at the top of the procedure. After the step completes, call `UpgradeTag.SetUpgradeTag(MyTag())`. Define the tag string in a `Tok`-suffixed Label or returning function so the same constant is referenced at both the guard and the registration (see `register-upgrade-tags-with-getpercompany-getperdatabase-subscribers`). + +See sample: `use-upgrade-tags-not-version-checks.good.al`. + +## Anti Pattern + +`if MyApp.DataVersion().Major < 18 then UpgradeFeatureA();` — the step runs on every upgrade from a pre-18 version, may fail on partial data, and the next retry re-runs work that already succeeded. Nesting version-check branches (`< 14` → step A, `< 17` → step B) compounds the fragility. + +See sample: `use-upgrade-tags-not-version-checks.bad.al`. From 287c0418442b5463d99bd523135dff5836c8c5e9 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 23 Apr 2026 17:18:17 +0200 Subject: [PATCH 08/15] Expand review skills to match the 6-domain knowledge corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The knowledge corpus now covers performance, security, privacy, upgrade, style, and UI. Previously only two leaf reviewer skills existed (al-performance-review, al-security-review), so four of the six domains had knowledge with no skill sourcing from them. A community reader landing in privacy/, upgrade/, style/, or ui/ would see articles with no apparent consumer. Three changes: 1. Move existing review skills into `microsoft/skills/review/`. The `review/` subfolder groups all review-kind skills together and leaves room for future non-review action skills at the `microsoft/skills/` level. Updates references in README.md, agent-consumption.md, and skills/entry.md to the new paths. 2. Add four new leaf reviewer skills — al-privacy-review, al-upgrade-review, al-style-review, al-ui-review — each following the same DO template as al-performance-review/al-security-review but sourcing from the corresponding knowledge domain. al-upgrade-review and al-ui-review return `not-applicable` when the diff contains no upgrade surface or no page files, respectively. 3. Update al-code-review to compose all six leaf skills and retarget the dangling references in every populated JSON example (`use-setloadfields.md`, `no-plaintext-secrets-in-telemetry.md`, `avoid-implicit-commit.md` — none of which exist in the corpus) to real knowledge files: `call-setloadfields-before-filters.md`, `use-secrettext-for-credentials.md`, `never-hardcode-secrets-in-al.md`. Validator passes with 0 errors / 0 warnings. --- README.md | 2 +- agent-consumption.md | 2 +- .../skills/{ => review}/al-code-review.md | 80 ++++++++------ .../{ => review}/al-performance-review.md | 16 +-- microsoft/skills/review/al-privacy-review.md | 102 ++++++++++++++++++ .../skills/{ => review}/al-security-review.md | 14 +-- microsoft/skills/review/al-style-review.md | 99 +++++++++++++++++ microsoft/skills/review/al-ui-review.md | 99 +++++++++++++++++ microsoft/skills/review/al-upgrade-review.md | 101 +++++++++++++++++ skills/entry.md | 8 +- 10 files changed, 472 insertions(+), 51 deletions(-) rename microsoft/skills/{ => review}/al-code-review.md (70%) rename microsoft/skills/{ => review}/al-performance-review.md (92%) create mode 100644 microsoft/skills/review/al-privacy-review.md rename microsoft/skills/{ => review}/al-security-review.md (90%) create mode 100644 microsoft/skills/review/al-style-review.md create mode 100644 microsoft/skills/review/al-ui-review.md create mode 100644 microsoft/skills/review/al-upgrade-review.md diff --git a/README.md b/README.md index b75a49d..1e353fc 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Skills define how agents consume knowledge. They come in three flavors: READ and DO are read on demand — typically when the first dispatched action skill runs. They are not prerequisites for invoking Entry. WRITE is only used when scaffolding new content. -- **Action skills** — concrete skills that follow the Action Skill template to do real work (review code, audit telemetry, etc.). Action skills live inside the layers that own them (`/microsoft/skills/`, `/community/skills/`, `/custom/skills/`). An action skill is either a **leaf** that evaluates knowledge files directly, or a **super-skill** that composes other action skills (declared via `sub-skills` in frontmatter). The canonical reference is [`microsoft/skills/al-code-review.md`](microsoft/skills/al-code-review.md) (super-skill), which composes [`microsoft/skills/al-performance-review.md`](microsoft/skills/al-performance-review.md) and [`microsoft/skills/al-security-review.md`](microsoft/skills/al-security-review.md) (leaves). +- **Action skills** — concrete skills that follow the Action Skill template to do real work (review code, audit telemetry, etc.). Action skills live inside the layers that own them (`/microsoft/skills/`, `/community/skills/`, `/custom/skills/`). An action skill is either a **leaf** that evaluates knowledge files directly, or a **super-skill** that composes other action skills (declared via `sub-skills` in frontmatter). The canonical reference is [`microsoft/skills/review/al-code-review.md`](microsoft/skills/review/al-code-review.md) (super-skill), which composes six leaf skills under [`microsoft/skills/review/`](microsoft/skills/review/) — one per knowledge domain (performance, security, privacy, upgrade, style, UI). ### Agent bootstrapping diff --git a/agent-consumption.md b/agent-consumption.md index 6ee07b7..f000acf 100644 --- a/agent-consumption.md +++ b/agent-consumption.md @@ -35,7 +35,7 @@ The agent reads `/skills/entry.md` and runs it against the task context. Entry a The dispatch record names one or more action skills and the subset of inputs each should receive. If the outcome is `no-match` or `failed`, the agent returns the record to the orchestrator unchanged. ### 4. Agent invokes each dispatched action skill -Action skills live inside the layers — `/microsoft/skills/`, `/community/skills/`, `/custom/skills/` — so their authority is carried by their location. For a PR review, Entry typically dispatches `microsoft/skills/al-code-review.md`. The agent reads the file and executes it. +Action skills live inside the layers — `/microsoft/skills/`, `/community/skills/`, `/custom/skills/` — so their authority is carried by their location. For a PR review, Entry typically dispatches `microsoft/skills/review/al-code-review.md`. The agent reads the file and executes it. ### 5. Action skill executes the four-step pattern diff --git a/microsoft/skills/al-code-review.md b/microsoft/skills/review/al-code-review.md similarity index 70% rename from microsoft/skills/al-code-review.md rename to microsoft/skills/review/al-code-review.md index d60e78b..382590d 100644 --- a/microsoft/skills/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -3,7 +3,7 @@ kind: action-skill id: al-code-review version: 1 title: AL code review -description: Reviews AL source changes by composing the AL review leaf skills (performance, security, ...). +description: Reviews AL source changes by composing the AL review leaf skills (performance, security, privacy, upgrade, style, UI). inputs: [pr-diff, file-path] outputs: [findings-report] bc-version: [all] @@ -11,8 +11,12 @@ technologies: [al] countries: [w1] application-area: [all] sub-skills: - - microsoft/skills/al-performance-review.md - - microsoft/skills/al-security-review.md + - microsoft/skills/review/al-performance-review.md + - microsoft/skills/review/al-security-review.md + - microsoft/skills/review/al-privacy-review.md + - microsoft/skills/review/al-upgrade-review.md + - microsoft/skills/review/al-style-review.md + - microsoft/skills/review/al-ui-review.md --- # AL code review @@ -27,10 +31,14 @@ An orchestrator invokes this skill with either a `pr-diff` (the standard PR-revi The sub-skills invoked by this skill are those listed in frontmatter `sub-skills`: -- `microsoft/skills/al-performance-review.md` -- `microsoft/skills/al-security-review.md` +- `microsoft/skills/review/al-performance-review.md` +- `microsoft/skills/review/al-security-review.md` +- `microsoft/skills/review/al-privacy-review.md` +- `microsoft/skills/review/al-upgrade-review.md` +- `microsoft/skills/review/al-style-review.md` +- `microsoft/skills/review/al-ui-review.md` -Additional leaf skills (for example, UX, telemetry, testing) are added by updating the `sub-skills` list. The skill does not discover sub-skills implicitly. +Additional leaf skills (for example, telemetry, testing) are added by updating the `sub-skills` list. The skill does not discover sub-skills implicitly. ## Relevance @@ -74,7 +82,7 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "skill": { "id": "al-code-review", "version": 1 }, "outcome": "completed", "summary": { - "counts": { "blocker": 1, "major": 1, "minor": 1, "info": 1 }, + "counts": { "blocker": 1, "major": 1, "minor": 2, "info": 0 }, "coverage": { "worklist-size": 4, "items-evaluated": 4 } }, "findings": [ @@ -94,40 +102,44 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "from-sub-skill": "al-performance-review" }, { - "id": "community/knowledge/performance/use-setloadfields.md", - "severity": "info", - "message": "Posting routine iterates ledger entries; consider whether SetLoadFields applies per the linked guidance.", + "id": "community/knowledge/performance/call-setloadfields-before-filters.md", + "severity": "minor", + "message": "SetLoadFields is called after SetRange. Per the referenced guidance the call must come before filters to be folded into the query plan.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 152 + }, "references": [ - { "path": "community/knowledge/performance/use-setloadfields.md" } + { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" } ], - "confidence": "low", + "confidence": "high", "from-sub-skill": "al-performance-review" }, { - "id": "microsoft/knowledge/security/no-plaintext-secrets-in-telemetry.md", + "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md", "severity": "blocker", - "message": "A bearer token is passed to Session.LogMessage as part of the CustomDimensions payload. The referenced guidance documents this as a platform-level data-protection violation.", + "message": "A bearer token is declared as a Text parameter and passed through the HTTP request path as plain text. The referenced guidance requires credentials to flow as SecretText end-to-end.", "location": { "file": "src/Integration/ApiClient.Codeunit.al", "line": 85, "range": { "start-line": 85, "end-line": 89 } }, "references": [ - { "path": "microsoft/knowledge/security/no-plaintext-secrets-in-telemetry.md" } + { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" } ], "confidence": "high", "from-sub-skill": "al-security-review" }, { - "id": "microsoft/knowledge/security/avoid-implicit-commit.md", + "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md", "severity": "minor", - "message": "An explicit COMMIT inside a posting routine may leave the ledger in an inconsistent state if subsequent steps fail.", + "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.", "location": { - "file": "src/Sales/PostingRoutines.Codeunit.al", + "file": "src/Integration/ApiClient.Codeunit.al", "line": 201 }, "references": [ - { "path": "microsoft/knowledge/security/avoid-implicit-commit.md" } + { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" } ], "confidence": "medium", "from-sub-skill": "al-security-review" @@ -139,7 +151,7 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "skill": { "id": "al-performance-review", "version": 1 }, "outcome": "completed", "summary": { - "counts": { "blocker": 0, "major": 1, "minor": 0, "info": 1 }, + "counts": { "blocker": 0, "major": 1, "minor": 1, "info": 0 }, "coverage": { "worklist-size": 2, "items-evaluated": 2 } }, "findings": [ @@ -158,13 +170,17 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "confidence": "high" }, { - "id": "community/knowledge/performance/use-setloadfields.md", - "severity": "info", - "message": "Posting routine iterates ledger entries; consider whether SetLoadFields applies per the linked guidance.", + "id": "community/knowledge/performance/call-setloadfields-before-filters.md", + "severity": "minor", + "message": "SetLoadFields is called after SetRange. Per the referenced guidance the call must come before filters to be folded into the query plan.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 152 + }, "references": [ - { "path": "community/knowledge/performance/use-setloadfields.md" } + { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" } ], - "confidence": "low" + "confidence": "high" } ], "suppressed": [] @@ -178,29 +194,29 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip }, "findings": [ { - "id": "microsoft/knowledge/security/no-plaintext-secrets-in-telemetry.md", + "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md", "severity": "blocker", - "message": "A bearer token is passed to Session.LogMessage as part of the CustomDimensions payload. The referenced guidance documents this as a platform-level data-protection violation.", + "message": "A bearer token is declared as a Text parameter and passed through the HTTP request path as plain text. The referenced guidance requires credentials to flow as SecretText end-to-end.", "location": { "file": "src/Integration/ApiClient.Codeunit.al", "line": 85, "range": { "start-line": 85, "end-line": 89 } }, "references": [ - { "path": "microsoft/knowledge/security/no-plaintext-secrets-in-telemetry.md" } + { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" } ], "confidence": "high" }, { - "id": "microsoft/knowledge/security/avoid-implicit-commit.md", + "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md", "severity": "minor", - "message": "An explicit COMMIT inside a posting routine may leave the ledger in an inconsistent state if subsequent steps fail.", + "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.", "location": { - "file": "src/Sales/PostingRoutines.Codeunit.al", + "file": "src/Integration/ApiClient.Codeunit.al", "line": 201 }, "references": [ - { "path": "microsoft/knowledge/security/avoid-implicit-commit.md" } + { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" } ], "confidence": "medium" } diff --git a/microsoft/skills/al-performance-review.md b/microsoft/skills/review/al-performance-review.md similarity index 92% rename from microsoft/skills/al-performance-review.md rename to microsoft/skills/review/al-performance-review.md index 5762391..4c143e4 100644 --- a/microsoft/skills/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -78,7 +78,7 @@ Output conforms to the DO output contract. A populated example: "skill": { "id": "al-performance-review", "version": 1 }, "outcome": "completed", "summary": { - "counts": { "blocker": 0, "major": 1, "minor": 0, "info": 1 }, + "counts": { "blocker": 0, "major": 1, "minor": 1, "info": 0 }, "coverage": { "worklist-size": 2, "items-evaluated": 2 } }, "findings": [ @@ -97,13 +97,17 @@ Output conforms to the DO output contract. A populated example: "confidence": "high" }, { - "id": "community/knowledge/performance/use-setloadfields.md", - "severity": "info", - "message": "Posting routine iterates ledger entries; consider whether SetLoadFields applies per the linked guidance.", + "id": "community/knowledge/performance/call-setloadfields-before-filters.md", + "severity": "minor", + "message": "SetLoadFields is called after SetRange. Per the referenced guidance the call must come before filters to be folded into the query plan.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 152 + }, "references": [ - { "path": "community/knowledge/performance/use-setloadfields.md" } + { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" } ], - "confidence": "low" + "confidence": "high" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md new file mode 100644 index 0000000..15a385c --- /dev/null +++ b/microsoft/skills/review/al-privacy-review.md @@ -0,0 +1,102 @@ +--- +kind: action-skill +id: al-privacy-review +version: 1 +title: AL privacy review +description: Reviews AL source changes against privacy and data-classification guidance from BCQuality. +inputs: [pr-diff, file-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL privacy review + +Reviews AL source changes against the `privacy` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. + +An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract. + +## Source + +Collect all knowledge files under `*/knowledge/privacy/**/*.md`, across every enabled layer (`/microsoft/`, `/community/`, `/custom/`). Relevance trims the result to the subset that applies. + +## Relevance + +Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context: + +- `bc-version` — the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`. +- `technologies` — `[al]`. +- `countries` — the countries declared in the consuming app's `app.json`. Default to the orchestrator's configured context; if absent, `unknown`. +- `application-area` — the union of application areas declared by the changed objects. Pass the actual set; do not substitute `[all]`. If the area cannot be determined from the changes, the dimension is `unknown`. + +Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the finding's `message` MUST name the dimension or dimensions that were unknown. + +## Worklist + +Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: + +- The changed AL object names and types — especially tables and tableextensions (for `DataClassification` on fields), codeunits that call `Error` or `Session.LogMessage`, codeunits performing outgoing HTTP requests with customer data, and objects reading or writing `IsolatedStorage`. +- The changed procedures and triggers, weighted toward those that call `Error`, `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`, `HttpClient.Post`/`Get`, `IsolatedStorage.Set`/`SetEncrypted`/`Get`, or `PrivacyNotice.GetPrivacyNoticeApprovalState`. +- Tokens extracted from the diff that relate to privacy (`DataClassification`, `CustomerContent`, `EndUserIdentifiableInformation`, `SystemMetadata`, `ToBeClassified`, `PrivacyNotice`, `GetLastErrorText`, `TelemetryScope`). + +A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from filename and Description) matches a changed object type. + +Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`. + +When the post-conflict worklist is empty because no applicable privacy knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable privacy knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. + +## Action + +For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows: + +- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the anti-pattern violates a platform-level guarantee (for example, documented telemetry-classification rules or GDPR-adjacent data-handling requirements). When the file does not make such a claim, the ceiling is `major`. +- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape. +- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. Repository-wide observations MAY omit `location`. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match (identifier, syntax, object type). +- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item; default when the skill finishes normally, including when the resulting `findings` array is empty. +- `no-knowledge` — no applicable privacy knowledge survived Source, Relevance, configuration filtering, and conflict resolution. `findings` is empty. +- `not-applicable` — the task context lacks an AL dimension (no AL changes in the diff, or `technologies` filter rejected the task). +- `partial` — a time or token budget was hit before the worklist was exhausted. `summary.coverage` reflects the evaluated subset; `outcome-reason` explains the cause. +- `failed` — an unrecoverable error occurred. `outcome-reason` is required. + +## Output + +Output conforms to the DO output contract. A populated example: + +```json +{ + "skill": { "id": "al-privacy-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 0, "major": 1, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 1, "items-evaluated": 1 } + }, + "findings": [ + { + "id": "microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md", + "severity": "major", + "message": "Error receives a pre-built Text produced by StrSubstNo with customer name and email as arguments. Per the referenced guidance the platform cannot classify or strip PII from an opaque Text and will export the full message to telemetry.", + "location": { + "file": "src/Sales/CustomerValidation.Codeunit.al", + "line": 64, + "range": { "start-line": 60, "end-line": 64 } + }, + "references": [ + { "path": "microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md" } + ], + "confidence": "high" + } + ], + "suppressed": [] +} +``` diff --git a/microsoft/skills/al-security-review.md b/microsoft/skills/review/al-security-review.md similarity index 90% rename from microsoft/skills/al-security-review.md rename to microsoft/skills/review/al-security-review.md index 1389785..126cd9c 100644 --- a/microsoft/skills/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -83,29 +83,29 @@ Output conforms to the DO output contract. A populated example: }, "findings": [ { - "id": "microsoft/knowledge/security/no-plaintext-secrets-in-telemetry.md", + "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md", "severity": "blocker", - "message": "A bearer token is passed to Session.LogMessage as part of the CustomDimensions payload. The referenced guidance documents this as a platform-level data-protection violation.", + "message": "A bearer token is declared as a Text parameter and passed through the HTTP request path as plain text. The referenced guidance requires credentials to flow as SecretText end-to-end.", "location": { "file": "src/Integration/ApiClient.Codeunit.al", "line": 85, "range": { "start-line": 85, "end-line": 89 } }, "references": [ - { "path": "microsoft/knowledge/security/no-plaintext-secrets-in-telemetry.md" } + { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" } ], "confidence": "high" }, { - "id": "microsoft/knowledge/security/avoid-implicit-commit.md", + "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md", "severity": "minor", - "message": "An explicit COMMIT inside a posting routine may leave the ledger in an inconsistent state if subsequent steps fail.", + "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.", "location": { - "file": "src/Sales/PostingRoutines.Codeunit.al", + "file": "src/Integration/ApiClient.Codeunit.al", "line": 201 }, "references": [ - { "path": "microsoft/knowledge/security/avoid-implicit-commit.md" } + { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" } ], "confidence": "medium" } diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md new file mode 100644 index 0000000..91f40f3 --- /dev/null +++ b/microsoft/skills/review/al-style-review.md @@ -0,0 +1,99 @@ +--- +kind: action-skill +id: al-style-review +version: 1 +title: AL style review +description: Reviews AL source changes against naming, labelling, and code-convention guidance from BCQuality. +inputs: [pr-diff, file-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL style review + +Reviews AL source changes against the `style` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. + +Style findings cover AL conventions that CodeCop and similar analyzers partially enforce — label suffixes, API page naming, temporary-variable prefixes, label properties, named invocations, `FieldCaption`/`TableCaption` in user messages, `OptionCaption` pairing, Error-parameter passing, `this` keyword, required parentheses, file-naming. Use together with a formal analyzer; this skill adds BCQuality's remedial-knowledge explanations of why each rule exists. + +An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces a single JSON document conforming to the DO output contract. + +## Source + +Collect all knowledge files under `*/knowledge/style/**/*.md`, across every enabled layer. + +## Relevance + +Apply the frontmatter matching rules defined in READ against the task context: + +- `bc-version` — the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`. +- `technologies` — `[al]`. +- `countries` — the countries declared in the consuming app's `app.json`. If absent, `unknown`. +- `application-area` — pass the actual set declared by the changed objects; do not substitute `[all]`. + +Discard files that are not applicable. Retain conditionally applicable files only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium` and MUST name the unknown dimensions in `message`. + +## Worklist + +Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: + +- Changed AL objects — especially API pages (`PageType = API`), tables and pages declaring Labels/TextConsts, codeunits issuing `Error`/`Message`/`Confirm`, and any file whose name violates the `..al` convention. +- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, option fields, error-handling call sites, and codeunit-internal method calls. +- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `OptionMembers`, `OptionCaption`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `this.`, `StrSubstNo`). + +A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic matches a changed object or declaration. + +Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions. + +When the post-conflict worklist is empty because no applicable style knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable style knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. + +## Action + +For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Style findings rarely reach `blocker` — reserve it for cases where the knowledge file documents a platform-level requirement (for example, API page property constraints the OData runtime rejects). Most style findings are `minor` or `info`; egregious misuse (`Error` with pre-built Text losing translation and telemetry classification) may reach `major`. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match. +- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item. +- `no-knowledge` — no applicable style knowledge survived filtering. +- `not-applicable` — no AL changes in the diff. +- `partial` — a budget was hit before the worklist was exhausted. +- `failed` — an unrecoverable error occurred. + +## Output + +Output conforms to the DO output contract. A populated example: + +```json +{ + "skill": { "id": "al-style-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 1, "info": 0 }, + "coverage": { "worklist-size": 1, "items-evaluated": 1 } + }, + "findings": [ + { + "id": "microsoft/knowledge/style/apply-approved-label-suffixes.md", + "severity": "minor", + "message": "A Label named Text000 has no approved suffix (Msg/Err/Qst/Tok/Lbl/Txt). Per the referenced CodeCop AA0074 guidance, every Label and TextConst carries a suffix indicating its consuming call.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 42 + }, + "references": [ + { "path": "microsoft/knowledge/style/apply-approved-label-suffixes.md" } + ], + "confidence": "high" + } + ], + "suppressed": [] +} +``` diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md new file mode 100644 index 0000000..70e8c52 --- /dev/null +++ b/microsoft/skills/review/al-ui-review.md @@ -0,0 +1,99 @@ +--- +kind: action-skill +id: al-ui-review +version: 1 +title: AL UI text review +description: Reviews AL page files against UI-text, caption, and tooltip guidance from BCQuality. +inputs: [pr-diff, file-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL UI text review + +Reviews AL page source against the `ui` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. + +UI findings apply to page files — files that declare `PageType = ...`, including `*.Page.al` under the standard file-naming convention. The skill returns `not-applicable` when the diff contains no page changes. + +An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces a single JSON document conforming to the DO output contract. + +## Source + +Collect all knowledge files under `*/knowledge/ui/**/*.md`, across every enabled layer. + +## Relevance + +Apply the frontmatter matching rules defined in READ against the task context: + +- `bc-version` — the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`. +- `technologies` — `[al]`. +- `countries` — the countries declared in the consuming app's `app.json`. If absent, `unknown`. +- `application-area` — pass the actual set declared by the changed objects; do not substitute `[all]`. + +Discard files that are not applicable. Retain conditionally applicable files only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium` and MUST name the unknown dimensions in `message`. + +## Worklist + +Narrow the relevant files to the subset that applies to the changes under review. + +- **Page-file filter.** UI review applies only to files declaring `page`, `pageextension`, or `pagecustomization`. When the diff contains no such files, return `outcome: "not-applicable"` without evaluating knowledge files. +- For each relevant knowledge file, compute overlap against changed page declarations, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, action definitions, and field-level properties. +- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions). + +A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic matches a changed page element. + +Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions. + +When the post-conflict worklist is empty because no applicable UI knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable UI knowledge matched the page changes, emit `outcome: "completed"` with an empty `findings` array. + +## Action + +For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. UI text findings are generally `minor` — they affect localization and polish rather than correctness. Reach for `major` only when a banned term appears in customer-facing text or a caption truncation is guaranteed at the stated character limit. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match (banned term literal, missing "Specifies" opener on a field tooltip, caption exceeding documented limit). +- `medium` when detection relies on heuristics (judging whether a caption is a noun phrase or a sentence phrase) or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item. +- `no-knowledge` — no applicable UI knowledge survived filtering. +- `not-applicable` — the diff contains no page, pageextension, or pagecustomization files. +- `partial` — a budget was hit before the worklist was exhausted. +- `failed` — an unrecoverable error occurred. + +## Output + +Output conforms to the DO output contract. A populated example: + +```json +{ + "skill": { "id": "al-ui-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 1, "info": 0 }, + "coverage": { "worklist-size": 1, "items-evaluated": 1 } + }, + "findings": [ + { + "id": "microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md", + "severity": "minor", + "message": "Field ToolTip is a fragment ('Customer name') — missing the 'Specifies' opener and the terminating period the house-style guidance requires.", + "location": { + "file": "src/Sales/CustomerCard.Page.al", + "line": 58 + }, + "references": [ + { "path": "microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md" } + ], + "confidence": "high" + } + ], + "suppressed": [] +} +``` diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md new file mode 100644 index 0000000..b66ed36 --- /dev/null +++ b/microsoft/skills/review/al-upgrade-review.md @@ -0,0 +1,101 @@ +--- +kind: action-skill +id: al-upgrade-review +version: 1 +title: AL upgrade review +description: Reviews AL source changes against upgrade-code and migration guidance from BCQuality. +inputs: [pr-diff, file-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL upgrade review + +Reviews AL source changes against the `upgrade` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. + +An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Upgrade findings are narrow by design — they apply when the diff touches upgrade codeunits, install codeunits, table schema, enums, or objects under migration namespaces. The skill returns `not-applicable` when none of those apply. + +## Source + +Collect all knowledge files under `*/knowledge/upgrade/**/*.md`, across every enabled layer (`/microsoft/`, `/community/`, `/custom/`). Relevance trims the result to the subset that applies. + +## Relevance + +Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context: + +- `bc-version` — the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`. +- `technologies` — `[al]`. +- `countries` — the countries declared in the consuming app's `app.json`. Default to the orchestrator's configured context; if absent, `unknown`. +- `application-area` — the union of application areas declared by the changed objects. Pass the actual set; do not substitute `[all]`. If the area cannot be determined from the changes, the dimension is `unknown`. + +Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the finding's `message` MUST name the dimension or dimensions that were unknown. + +## Worklist + +Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: + +- The changed AL object names and types — especially codeunits with `Subtype = Upgrade` or `Subtype = Install`, tables and tableextensions adding or changing fields, enums and enumextensions, and objects under `Hybrid*`/`Migration`/`Upgrade` namespaces. +- The changed triggers and procedures, weighted toward `OnUpgradePerCompany`, `OnUpgradePerDatabase`, `OnInstallAppPerCompany`, and the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers. +- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `DataTransfer`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `DataVersion`, `ExecutionContext`, `value(`, `enum`, `enumextension`). + +A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic matches a changed object type. When the diff contains no upgrade-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. + +Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files suppressed by configuration are recorded with `reason: "configuration"`. + +When the post-conflict worklist is empty because no applicable upgrade knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable upgrade knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. + +## Action + +For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows: + +- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` for irreversible data corruption (enum-ordinal shift, unguarded reads that abort the upgrade) and for changes that would ship to customers without a migration path (new InitValue on an existing table without upgrade code). +- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape. +- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match. +- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item. +- `no-knowledge` — no applicable upgrade knowledge survived filtering. +- `not-applicable` — the diff touches no upgrade, install, schema, or enum surface. +- `partial` — a budget was hit before the worklist was exhausted. +- `failed` — an unrecoverable error occurred. + +## Output + +Output conforms to the DO output contract. A populated example: + +```json +{ + "skill": { "id": "al-upgrade-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 1, "major": 0, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 1, "items-evaluated": 1 } + }, + "findings": [ + { + "id": "microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md", + "severity": "blocker", + "message": "A new enum value was inserted at ordinal 1, shifting every subsequent value by one. Rows that store the old ordinal 1 will silently resolve to the new value. Per the referenced guidance, enum values must be appended at the end.", + "location": { + "file": "src/Shared/OrderStatus.Enum.al", + "line": 7 + }, + "references": [ + { "path": "microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md" } + ], + "confidence": "high" + } + ], + "suppressed": [] +} +``` diff --git a/skills/entry.md b/skills/entry.md index f1c5798..d63ca2e 100644 --- a/skills/entry.md +++ b/skills/entry.md @@ -76,7 +76,7 @@ Emit a single JSON document conforming to the output contract below. Entry does "skill": { "id": "al-code-review", "version": 1, - "path": "microsoft/skills/al-code-review.md" + "path": "microsoft/skills/review/al-code-review.md" }, "rationale": "string", "inputs": ["pr-diff"] @@ -141,14 +141,14 @@ Populated example (PR review on a repo where only `al-performance-review` is ena "outcome": "routed", "dispatch": [ { - "skill": { "id": "al-performance-review", "version": 1, "path": "microsoft/skills/al-performance-review.md" }, + "skill": { "id": "al-performance-review", "version": 1, "path": "microsoft/skills/review/al-performance-review.md" }, "rationale": "Goal 'review pull request' matched; inputs-available contains pr-diff.", "inputs": ["pr-diff"] } ], "skipped": [ - { "skill": { "id": "al-code-review", "path": "microsoft/skills/al-code-review.md" }, "reason": "configuration" }, - { "skill": { "id": "al-security-review", "path": "microsoft/skills/al-security-review.md" }, "reason": "configuration" } + { "skill": { "id": "al-code-review", "path": "microsoft/skills/review/al-code-review.md" }, "reason": "configuration" }, + { "skill": { "id": "al-security-review", "path": "microsoft/skills/review/al-security-review.md" }, "reason": "configuration" } ] } ``` From 0540c7bf6e41299f2b9a1f2e458c5c7083d7e383 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 23 Apr 2026 17:31:36 +0200 Subject: [PATCH 09/15] Reframe seed-article banners as community contribution invitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 35 articles still in their seed form previously carried a banner reading "Seed article. ... Domain stewards should expand, restructure, and refine as needed." For a community preview, that phrasing reads as "TODO left in production" to first-time visitors. Replace all three banner variants (performance-seeded, security-seeded, community-ported) with a single positive invitation: > Contributions welcome — open a PR to refine or extend this article. Content and structure of the articles are unchanged; only the leading quote block differs. Articles that had their banner fully stripped in the earlier triage pass (the showcase-grade ten) are unaffected. --- .../avoid-growing-globals-in-singleinstance-subscribers.md | 2 +- .../performance/choose-maintainsiftindex-by-read-write-ratio.md | 2 +- .../performance/load-common-fields-before-branching-on-case.md | 2 +- .../load-only-primary-key-fields-for-reference-work.md | 2 +- .../performance/omit-filter-only-fields-from-setloadfields.md | 2 +- .../knowledge/performance/order-case-branches-by-frequency.md | 2 +- .../performance/use-deleteall-for-filtered-bulk-deletion.md | 2 +- .../security/compose-permission-sets-with-included-sets.md | 2 +- .../security/do-not-grant-rights-beyond-a-users-entitlement.md | 2 +- .../security/guard-bulk-operations-with-istemporary.md | 2 +- .../prefer-oauth2-over-api-keys-for-external-http-calls.md | 2 +- .../security/protect-sensitive-data-in-temporary-tables.md | 2 +- microsoft/knowledge/performance/avoid-commit-inside-loops.md | 2 +- .../performance/avoid-user-interaction-in-transactions.md | 2 +- .../knowledge/performance/keep-event-subscribers-lightweight.md | 2 +- microsoft/knowledge/performance/only-fetch-records-you-use.md | 2 +- .../performance/prefer-direct-record-over-recordref.md | 2 +- .../knowledge/performance/prefer-get-for-primary-key-lookups.md | 2 +- .../knowledge/performance/set-current-key-to-match-filters.md | 2 +- .../performance/use-addloadfields-in-report-layouts.md | 2 +- .../knowledge/performance/use-calcsums-for-flowfield-totals.md | 2 +- microsoft/knowledge/performance/use-findset-with-next.md | 2 +- .../performance/use-insert-false-when-skipping-triggers.md | 2 +- .../knowledge/performance/use-isempty-for-existence-checks.md | 2 +- .../performance/use-single-instance-codeunits-for-caching.md | 2 +- .../performance/use-temporary-tables-for-intermediate-data.md | 2 +- .../knowledge/security/compose-secrets-with-secretstrsubstno.md | 2 +- .../do-not-expose-sensitive-data-in-event-publishers.md | 2 +- .../security/follow-least-privilege-in-permission-sets.md | 2 +- microsoft/knowledge/security/never-hardcode-secrets-in-al.md | 2 +- .../security/prefer-azure-key-vault-for-production-secrets.md | 2 +- .../security/use-indirect-permissions-for-elevated-access.md | 2 +- .../use-inherent-permissions-to-grant-minimal-access.md | 2 +- .../use-isolated-storage-for-module-and-company-secrets.md | 2 +- .../security/use-nondebuggable-when-parsing-secrets.md | 2 +- 35 files changed, 35 insertions(+), 35 deletions(-) diff --git a/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md b/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md index ed95723..966e0dd 100644 --- a/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md +++ b/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md @@ -9,7 +9,7 @@ application-area: [all] # Avoid growing globals in SingleInstance subscribers -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md b/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md index e0bc686..f320dbc 100644 --- a/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md +++ b/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md @@ -9,7 +9,7 @@ application-area: [all] # Choose MaintainSIFTIndex by read-write ratio -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/performance/load-common-fields-before-branching-on-case.md b/community/knowledge/performance/load-common-fields-before-branching-on-case.md index bee6065..f91d72e 100644 --- a/community/knowledge/performance/load-common-fields-before-branching-on-case.md +++ b/community/knowledge/performance/load-common-fields-before-branching-on-case.md @@ -9,7 +9,7 @@ application-area: [all] # Load common fields before branching on case -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md b/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md index 3b4b8f9..533ab8c 100644 --- a/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md +++ b/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md @@ -9,7 +9,7 @@ application-area: [all] # Load only primary key fields for reference work -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md b/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md index 017b567..c475f1b 100644 --- a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md +++ b/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md @@ -9,7 +9,7 @@ application-area: [all] # Omit filter-only fields from SetLoadFields -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/performance/order-case-branches-by-frequency.md b/community/knowledge/performance/order-case-branches-by-frequency.md index 9f78100..5768004 100644 --- a/community/knowledge/performance/order-case-branches-by-frequency.md +++ b/community/knowledge/performance/order-case-branches-by-frequency.md @@ -9,7 +9,7 @@ application-area: [all] # Order case branches by frequency -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md b/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md index 101672a..0c5a1de 100644 --- a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md +++ b/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md @@ -9,7 +9,7 @@ application-area: [all] # Use DeleteAll for filtered bulk deletion -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/security/compose-permission-sets-with-included-sets.md b/community/knowledge/security/compose-permission-sets-with-included-sets.md index b072a67..7fb94cb 100644 --- a/community/knowledge/security/compose-permission-sets-with-included-sets.md +++ b/community/knowledge/security/compose-permission-sets-with-included-sets.md @@ -9,7 +9,7 @@ application-area: [all] # Compose permission sets with IncludedPermissionSets -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md b/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md index fe68d58..5334ab7 100644 --- a/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md +++ b/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md @@ -9,7 +9,7 @@ application-area: [all] # Do not grant rights beyond a user's entitlement -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/security/guard-bulk-operations-with-istemporary.md b/community/knowledge/security/guard-bulk-operations-with-istemporary.md index b3559a6..7cb53f8 100644 --- a/community/knowledge/security/guard-bulk-operations-with-istemporary.md +++ b/community/knowledge/security/guard-bulk-operations-with-istemporary.md @@ -9,7 +9,7 @@ application-area: [all] # Guard bulk operations with IsTemporary -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md b/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md index 7ae5e24..f12a4f9 100644 --- a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md +++ b/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md @@ -9,7 +9,7 @@ application-area: [all] # Prefer OAuth2 over API keys for external HTTP calls -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md b/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md index 37ce915..3f4db02 100644 --- a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md +++ b/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md @@ -9,7 +9,7 @@ application-area: [all] # Protect sensitive data in temporary tables -> **Seed article.** Ported from BC Code Intelligence to seed the community corpus. Community contributors are invited to expand or refine. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.md b/microsoft/knowledge/performance/avoid-commit-inside-loops.md index fefd4f3..b02952c 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.md +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.md @@ -9,7 +9,7 @@ application-area: [all] # Do not Commit inside loops -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md b/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md index bf0895f..548ee24 100644 --- a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md +++ b/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md @@ -9,7 +9,7 @@ application-area: [all] # Do not prompt the user inside a write transaction -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md index 5079bf2..a1fae2a 100644 --- a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md +++ b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md @@ -9,7 +9,7 @@ application-area: [all] # Keep event subscribers lightweight -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/only-fetch-records-you-use.md b/microsoft/knowledge/performance/only-fetch-records-you-use.md index b0d7a61..3a1e40e 100644 --- a/microsoft/knowledge/performance/only-fetch-records-you-use.md +++ b/microsoft/knowledge/performance/only-fetch-records-you-use.md @@ -9,7 +9,7 @@ application-area: [all] # Only fetch records you use -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md index dce8733..f9b76b1 100644 --- a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md +++ b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md @@ -9,7 +9,7 @@ application-area: [all] # Prefer direct record access over RecordRef where possible -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md b/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md index 8dfa92b..e7d4d88 100644 --- a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md +++ b/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md @@ -9,7 +9,7 @@ application-area: [all] # Prefer Get for primary-key lookups -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/set-current-key-to-match-filters.md b/microsoft/knowledge/performance/set-current-key-to-match-filters.md index d3525aa..95effd7 100644 --- a/microsoft/knowledge/performance/set-current-key-to-match-filters.md +++ b/microsoft/knowledge/performance/set-current-key-to-match-filters.md @@ -9,7 +9,7 @@ application-area: [all] # Set the current key to match your filters -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md b/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md index 0073b07..3e14fdc 100644 --- a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md +++ b/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md @@ -9,7 +9,7 @@ application-area: [all] # Use AddLoadFields in report dataitems -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md b/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md index f934cae..6cb4a55 100644 --- a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md +++ b/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md @@ -9,7 +9,7 @@ application-area: [all] # Use CalcSums to aggregate filtered sets -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/use-findset-with-next.md b/microsoft/knowledge/performance/use-findset-with-next.md index 2232a05..c78c1aa 100644 --- a/microsoft/knowledge/performance/use-findset-with-next.md +++ b/microsoft/knowledge/performance/use-findset-with-next.md @@ -9,7 +9,7 @@ application-area: [all] # Use FindSet with Next for iteration -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md b/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md index a1391ba..708f53e 100644 --- a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md +++ b/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md @@ -9,7 +9,7 @@ application-area: [all] # Choose Insert, Modify, and Delete parameters deliberately -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-checks.md b/microsoft/knowledge/performance/use-isempty-for-existence-checks.md index 58b483a..35955fc 100644 --- a/microsoft/knowledge/performance/use-isempty-for-existence-checks.md +++ b/microsoft/knowledge/performance/use-isempty-for-existence-checks.md @@ -9,7 +9,7 @@ application-area: [all] # Use IsEmpty for existence checks -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md b/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md index d8303ff..9c8d6f9 100644 --- a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md +++ b/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md @@ -9,7 +9,7 @@ application-area: [all] # Use SingleInstance codeunits for session caching -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md b/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md index c5ca053..0beb323 100644 --- a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md +++ b/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md @@ -9,7 +9,7 @@ application-area: [all] # Use temporary tables for intermediate data -> **Seed article.** Converted from an existing performance-review prompt to bootstrap the BCQuality performance corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.md b/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.md index 3f87594..fb849d0 100644 --- a/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.md +++ b/microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.md @@ -9,7 +9,7 @@ application-area: [all] # Compose secrets with SecretStrSubstNo -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md index 7ad3ad3..e743bf1 100644 --- a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md +++ b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md @@ -9,7 +9,7 @@ application-area: [all] # Do not expose sensitive data in event publishers -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md b/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md index 09290f8..444ceed 100644 --- a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md +++ b/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md @@ -9,7 +9,7 @@ application-area: [all] # Follow least privilege in permission sets -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/security/never-hardcode-secrets-in-al.md b/microsoft/knowledge/security/never-hardcode-secrets-in-al.md index 0d333c2..6822b4a 100644 --- a/microsoft/knowledge/security/never-hardcode-secrets-in-al.md +++ b/microsoft/knowledge/security/never-hardcode-secrets-in-al.md @@ -9,7 +9,7 @@ application-area: [all] # Never hardcode secrets in AL -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md b/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md index 7a35168..382f8e8 100644 --- a/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md +++ b/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md @@ -9,7 +9,7 @@ application-area: [all] # Prefer Azure Key Vault for production secrets -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md b/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md index d93efea..58f69ac 100644 --- a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md +++ b/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md @@ -9,7 +9,7 @@ application-area: [all] # Use indirect permissions for elevated access -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md b/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md index d5b33f4..7056595 100644 --- a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md +++ b/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md @@ -9,7 +9,7 @@ application-area: [all] # Use InherentPermissions to grant minimal access -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md index a5e00f0..3ef53a1 100644 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md +++ b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md @@ -9,7 +9,7 @@ application-area: [all] # Use IsolatedStorage for module and company secrets -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md index f2b0d5c..819a310 100644 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md +++ b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md @@ -9,7 +9,7 @@ application-area: [all] # Use NonDebuggable when parsing secrets -> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed. +> Contributions welcome — open a PR to refine or extend this article. ## Description From 4ce7d816cc2b5821b80b73df0afb217c9376e768 Mon Sep 17 00:00:00 2001 From: Volodymyr Dvernytskyi Date: Thu, 23 Apr 2026 20:58:02 +0300 Subject: [PATCH 10/15] Transaction and error handling in BC AL - new knowledge articles --- .../avoid-commit-inside-loops.bad.al | 13 +++---- .../avoid-commit-inside-loops.good.al | 21 +++++++++++ .../performance/avoid-commit-inside-loops.md | 8 ++-- ...odeunit-run-as-atomic-sub-operation.bad.al | 12 ++++++ ...deunit-run-as-atomic-sub-operation.good.al | 29 +++++++++++++++ .../codeunit-run-as-atomic-sub-operation.md | 26 +++++++++++++ ...res-prior-commit-inside-transaction.bad.al | 27 ++++++++++++++ ...es-prior-commit-inside-transaction.good.al | 37 +++++++++++++++++++ ...equires-prior-commit-inside-transaction.md | 26 +++++++++++++ ...nderstand-implicit-transaction-boundary.md | 22 +++++++++++ ...ion-for-error-catching-not-rollback.bad.al | 17 +++++++++ ...on-for-error-catching-not-rollback.good.al | 23 ++++++++++++ ...unction-for-error-catching-not-rollback.md | 28 ++++++++++++++ ...r-attribute-scopes-explicit-commits.bad.al | 26 +++++++++++++ ...-attribute-scopes-explicit-commits.good.al | 27 ++++++++++++++ ...avior-attribute-scopes-explicit-commits.md | 26 +++++++++++++ ...attribute-governs-test-transactions.bad.al | 10 +++++ ...ttribute-governs-test-transactions.good.al | 21 +++++++++++ ...del-attribute-governs-test-transactions.md | 26 +++++++++++++ 19 files changed, 415 insertions(+), 10 deletions(-) create mode 100644 microsoft/knowledge/performance/avoid-commit-inside-loops.good.al create mode 100644 microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.bad.al create mode 100644 microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.good.al create mode 100644 microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md create mode 100644 microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.bad.al create mode 100644 microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.good.al create mode 100644 microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md create mode 100644 microsoft/knowledge/performance/understand-implicit-transaction-boundary.md create mode 100644 microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.bad.al create mode 100644 microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.good.al create mode 100644 microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md create mode 100644 microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.bad.al create mode 100644 microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.good.al create mode 100644 microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.md create mode 100644 microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al create mode 100644 microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al create mode 100644 microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al b/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al index c2646e6..feacfcc 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al @@ -1,15 +1,14 @@ codeunit 50129 "Perf Sample CommitInLoop Bad" { - procedure ReleaseAllOrders() + procedure NormalizeCustomerNames() var - SalesHeader: Record "Sales Header"; + Customer: Record Customer; begin - SalesHeader.SetRange(Status, SalesHeader.Status::Open); - if SalesHeader.FindSet() then + if Customer.FindSet(true) then repeat - SalesHeader.Status := SalesHeader.Status::Released; - SalesHeader.Modify(); + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); Commit(); - until SalesHeader.Next() = 0; + until Customer.Next() = 0; end; } diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al b/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al new file mode 100644 index 0000000..afd57a2 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al @@ -0,0 +1,21 @@ +codeunit 50128 "Perf Sample CommitInLoop Good" +{ + procedure NormalizeCustomerNames() + var + Customer: Record Customer; + RowsInChunk: Integer; + ChunkSize: Integer; + begin + ChunkSize := 500; + if Customer.FindSet(true) then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); + RowsInChunk += 1; + if RowsInChunk >= ChunkSize then begin + Commit(); + RowsInChunk := 0; + end; + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.md b/microsoft/knowledge/performance/avoid-commit-inside-loops.md index b02952c..98e0c38 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.md +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: performance -keywords: [commit, loop, transaction, lock] +keywords: [commit, loop, transaction, lock, checkpoint, codeunit-run] technologies: [al] countries: [w1] application-area: [all] @@ -13,11 +13,13 @@ application-area: [all] ## Description -Commit ends the current transaction. Calling it inside a loop produces one transaction per iteration and loses the ability to roll back the whole operation atomically. It also interferes with the platform's ability to batch write operations. The original motivation — releasing locks during a long batch — is better served by splitting the batch into explicit checkpoints that each process a bounded number of rows. +Commit ends the current write transaction. Calling it inside a per-row loop produces one transaction per iteration and loses the ability to roll back the whole operation atomically; it also interferes with the platform's ability to batch write operations. Most loops need no explicit Commit at all — AL auto-commits the enclosing code module on successful completion (see `understand-implicit-transaction-boundary.md`). When the batch is too large for one transaction, the fix is not a per-row Commit but bounded checkpoints that each process N rows. ## Best Practice -If the batch is large enough that a single transaction is untenable, process it in checkpoints driven by an outer loop that each time picks up the next N rows. Commit once per checkpoint at a clearly defined safe boundary, not inside the per-row loop. +If the batch is large enough that a single transaction is untenable, process it in checkpoints driven by an outer loop that each time picks up the next N rows. Commit once per checkpoint at a clearly defined safe boundary, not inside the per-row loop. Wrapping each chunk in `Codeunit.Run` gives the same effect with native rollback on failure — see `codeunit-run-as-atomic-sub-operation.md`. + +See sample: `avoid-commit-inside-loops.good.al`. ## Anti Pattern diff --git a/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.bad.al b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.bad.al new file mode 100644 index 0000000..d74dfc1 --- /dev/null +++ b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.bad.al @@ -0,0 +1,12 @@ +codeunit 50144 "Perf Sample AtomicSub Bad" +{ + procedure ApplyDiscountToSelection(var Customer: Record Customer) + begin + if Customer.FindSet(true) then + repeat + Customer."Customer Price Group" := 'VIP'; + Customer.Modify(true); + Commit(); + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.good.al b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.good.al new file mode 100644 index 0000000..91d81e3 --- /dev/null +++ b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.good.al @@ -0,0 +1,29 @@ +codeunit 50142 "Perf Sample AtomicSub Good" +{ + procedure ApplyDiscountToSelection(var Customer: Record Customer) + var + ApplyOne: Codeunit "Perf Sample Apply Discount"; + begin + if Customer.FindSet() then + repeat + ClearLastError(); + if not ApplyOne.Run(Customer) then + LogSkipped(Customer."No.", GetLastErrorText()); + until Customer.Next() = 0; + end; + + local procedure LogSkipped(CustomerNo: Code[20]; ErrorText: Text) + begin + end; +} + +codeunit 50143 "Perf Sample Apply Discount" +{ + TableNo = Customer; + + trigger OnRun() + begin + Rec.Validate("Customer Price Group", 'VIP'); + Rec.Modify(true); + end; +} diff --git a/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md new file mode 100644 index 0000000..89777f1 --- /dev/null +++ b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [codeunit-run, atomic, rollback, transaction, sub-transaction, try-pattern, implicit-commit] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use Codeunit.Run to bound an atomic sub-operation + +## Description + +`Codeunit.Run(ID)` is the AL-idiomatic way to run a unit of work as an atomic sub-operation with its own transactional boundary. When the return value is captured — `if Codeunit.Run(MyCodeunit) then ...` — the runtime treats the codeunit as a unit: on successful completion it performs an implicit commit of the codeunit's database changes; on error it rolls those changes back and the caller receives `false`. Per the platform reference, "any changes done to the database will be committed at the end of the codeunit, unless an error occurs." The caller decides how to react — compensate, surface an error, continue — without having to manage transactions by hand. + +## Best Practice + +When a piece of work must either complete fully or have no effect, put it in its own codeunit and invoke it via `Codeunit.Run`, capturing the return. Use `if not Codeunit.Run(X) then Error(...)` to abort and unwind; use the plain boolean branch to react to failure without aborting the caller. This replaces the SQL-style `BEGIN TRAN / COMMIT / ROLLBACK` habit with a pattern the AL runtime implements natively. Do not confuse `Codeunit.Run` with `[TryFunction]` — both catch errors, but only `Codeunit.Run` rolls back database changes on failure (see `use-tryfunction-for-error-catching-not-rollback.md`). Note that if the caller is already in a write transaction, the platform requires a `Commit()` before `Codeunit.Run` — the sub-operation cannot nest inside an open transaction (see `codeunit-run-requires-prior-commit-inside-transaction.md`). + +See sample: `codeunit-run-as-atomic-sub-operation.good.al`. + +## Anti Pattern + +Inlining the work in the caller and sprinkling `Commit()` to simulate sub-transaction boundaries. The caller's enclosing transaction is fused to the sub-work; any Commit between checkpoints survives subsequent errors, and any errors after a Commit cannot be cleanly unwound. Per-row Commits (see `avoid-commit-inside-loops.md`) are a frequent symptom. + +See sample: `codeunit-run-as-atomic-sub-operation.bad.al`. diff --git a/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.bad.al b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.bad.al new file mode 100644 index 0000000..64cb7a2 --- /dev/null +++ b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.bad.al @@ -0,0 +1,27 @@ +codeunit 50147 "Perf Sample OpenTxnRun Bad" +{ + procedure ApplyDiscountToSelection(var Customer: Record Customer) + var + ApplyOne: Codeunit "Perf Sample OpenTxnRun Apply"; + RunLog: Record "Custom Run Log"; + begin + if Customer.FindSet() then + repeat + RunLog.Init(); + RunLog."Customer No." := Customer."No."; + RunLog.Insert(); + if not ApplyOne.Run(Customer) then; + until Customer.Next() = 0; + end; +} + +codeunit 50148 "Perf Sample OpenTxnRun Apply" +{ + TableNo = Customer; + + trigger OnRun() + begin + Rec.Validate("Customer Price Group", 'VIP'); + Rec.Modify(true); + end; +} diff --git a/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.good.al b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.good.al new file mode 100644 index 0000000..ac9fcfb --- /dev/null +++ b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.good.al @@ -0,0 +1,37 @@ +codeunit 50145 "Perf Sample DeferredLog Good" +{ + procedure ApplyDiscountToSelection(var Customer: Record Customer) + var + ApplyOne: Codeunit "Perf Sample DeferredLog Apply"; + FailedCustomerNos: List of [Code[20]]; + FailureReasons: List of [Text]; + Index: Integer; + begin + if Customer.FindSet() then + repeat + ClearLastError(); + if not ApplyOne.Run(Customer) then begin + FailedCustomerNos.Add(Customer."No."); + FailureReasons.Add(GetLastErrorText()); + end; + until Customer.Next() = 0; + + for Index := 1 to FailedCustomerNos.Count() do + WriteFailureLog(FailedCustomerNos.Get(Index), FailureReasons.Get(Index)); + end; + + local procedure WriteFailureLog(CustomerNo: Code[20]; Reason: Text) + begin + end; +} + +codeunit 50146 "Perf Sample DeferredLog Apply" +{ + TableNo = Customer; + + trigger OnRun() + begin + Rec.Validate("Customer Price Group", 'VIP'); + Rec.Modify(true); + end; +} diff --git a/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md new file mode 100644 index 0000000..a07520f --- /dev/null +++ b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [codeunit-run, commit, write-transaction, nesting, loop, runtime-error] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Commit before Codeunit.Run when the caller already holds a write transaction + +## Description + +`Codeunit.Run` cannot nest inside an open write transaction. Per the platform reference, "If you're already in a transaction you must commit first before calling `Codeunit.Run`." The platform enforces this at runtime: the first call dies with an error, not at compile time. The rule most often surfaces in a loop that pairs outer-scope writes — progress records, audit log entries, failure markers — with a per-item `Codeunit.Run`: the first outer write opens a transaction, the subsequent `Codeunit.Run` throws. `[CommitBehavior]` does not silence this, because the implicit commit inside `Codeunit.Run` is exempt from the attribute: "The `CommitBehavior` only applies to explicit commits, not implicit commits done as part of [Codeunit.Run]." `[TryFunction]` is not a substitute either: a try method catches errors but does not open its own rollback boundary (see `use-tryfunction-for-error-catching-not-rollback.md`). + +## Best Practice + +For the `Codeunit.Run` atomic-sub-operation pattern (see `codeunit-run-as-atomic-sub-operation.md`) to work in a loop, keep the outer scope **read-only**. Move per-iteration writes — progress updates, logging, audit entries — into the sub-codeunit so they commit or roll back together with the per-item work. If logging must live outside the atomic boundary, defer it: collect failure info in memory during the loop (a `List of [Text]`, a temporary record, local variables) and write it in one pass after the loop ends, when no outer write transaction is open. + +See sample: `codeunit-run-requires-prior-commit-inside-transaction.good.al`. + +## Anti Pattern + +Inserting `Commit()` before each `Codeunit.Run` to silence the runtime error. The error goes away, but the outer scope now commits per iteration — the behavior `avoid-commit-inside-loops.md` exists to warn against. Attempting to silence the implicit commit inside the sub-codeunit with `[CommitBehavior(CommitBehavior::Ignore)]` also fails: the attribute does not apply to `Codeunit.Run`'s implicit commit. Conditioning the Commit on `Database.IsInWriteTransaction()` (runtime 11.0+) is another version of the same trap — the method has legitimate uses for diagnostics and library code that genuinely cannot control its caller, but branching production flow on runtime transaction state typically signals unclear ownership that would be better fixed by restructuring the caller so transaction state is predictable. + +See sample: `codeunit-run-requires-prior-commit-inside-transaction.bad.al`. diff --git a/microsoft/knowledge/performance/understand-implicit-transaction-boundary.md b/microsoft/knowledge/performance/understand-implicit-transaction-boundary.md new file mode 100644 index 0000000..da9a1eb --- /dev/null +++ b/microsoft/knowledge/performance/understand-implicit-transaction-boundary.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [commit, transaction, implicit-commit, write-transaction, runtime, boundary] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL auto-commits when code execution completes + +## Description + +In AL, write transactions are managed by the runtime, not by the developer. When AL code begins executing from an entry point — an outermost trigger, a codeunit invoked via `Codeunit.Run`, a report, a page action — the runtime opens a write transaction on the first database write. When that execution completes without error, the runtime commits automatically; if that execution errors, uncommitted writes are rolled back. Explicit `Commit()` is not how write transactions are *started*; it is how a single execution is *split* into multiple transactions. Per the platform reference, "The Commit method separates write transactions in an AL code module." + +## Best Practice + +Default to no explicit `Commit()`. Let the runtime open and close the transaction around the execution. Reach for `Commit()` only when the execution has a real reason to persist partial progress — for example, a long batch that must release locks between checkpoints (see `avoid-commit-inside-loops.md`), or work that calls an external service and must persist the resulting handle before continuing with operations that may fail independently. If a stretch of work needs to either complete fully or have no effect, prefer `Codeunit.Run` over manual Commit choreography (see `codeunit-run-as-atomic-sub-operation.md`). + +## Anti Pattern + +Sprinkling `Commit()` defensively — at the end of a procedure, after every Modify, or "just to be safe" — reflects a SQL-style mental model that does not apply here. Every stray Commit shortens the rollback window: work before the Commit survives later errors the developer almost certainly intended to unwind. A Commit without a specific reason is a bug waiting to surface. diff --git a/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.bad.al b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.bad.al new file mode 100644 index 0000000..648bdfd --- /dev/null +++ b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.bad.al @@ -0,0 +1,17 @@ +codeunit 50156 "Perf Sample TryFunc Bad" +{ + procedure ApplyDiscountAttempt(var Customer: Record Customer) + begin + if not TryApplyDiscount(Customer) then + Message('Discount not applied'); + end; + + [TryFunction] + local procedure TryApplyDiscount(var Customer: Record Customer) + begin + Customer.Validate("Customer Price Group", 'VIP'); + Customer.Modify(true); + if Customer."Credit Limit (LCY)" <= 0 then + Error('Customer %1 not eligible', Customer."No."); + end; +} diff --git a/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.good.al b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.good.al new file mode 100644 index 0000000..4e6f747 --- /dev/null +++ b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.good.al @@ -0,0 +1,23 @@ +codeunit 50155 "Perf Sample TryFunc Good" +{ + procedure ParseAndProcess(Payload: Text) + var + ParsedValue: Decimal; + begin + ClearLastError(); + if not TryParseDecimal(Payload, ParsedValue) then begin + LogParseFailure(Payload, GetLastErrorText()); + exit; + end; + end; + + [TryFunction] + local procedure TryParseDecimal(Input: Text; var Result: Decimal) + begin + Evaluate(Result, Input); + end; + + local procedure LogParseFailure(Payload: Text; Reason: Text) + begin + end; +} diff --git a/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md new file mode 100644 index 0000000..bb015f6 --- /dev/null +++ b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [try-function, try-method, error-handling, rollback, atomic, exception, get-last-error, session-buffer] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use [TryFunction] for error catching, Codeunit.Run for atomic rollback + +## Description + +`[TryFunction]` annotates a method so that errors raised inside it can be caught by the caller instead of propagating. Per the platform reference, "changes to the database that are made with a try method aren't rolled back" — the attribute catches the error; it does not unwind database state. This is the critical distinction from `Codeunit.Run`, which does roll back on error (see `codeunit-run-as-atomic-sub-operation.md`). A try function also only catches when its return value is used: "If the return variable for a call to a function, which is attributed with [TryFunction] isn't used, then the call isn't considered a try function call." `DoTry();` propagates errors normally; only `ok := DoTry();` or `if DoTry() then ...` catches. The return type is forced to Boolean; user-defined return types are not allowed, and the value isn't accessible inside the try method itself. On Business Central on-premises, writes inside a try method are blocked by default and raise a runtime error unless `DisableWriteInsideTryFunctions` is set to `false` on the server — SaaS has no such restriction. + +## Best Practice + +Reach for `[TryFunction]` when you want to catch a failure without unwinding the transaction — HTTP calls whose non-2xx responses should surface a user-friendly message, .NET interop whose exceptions you want to translate, validation or parsing routines whose errors you intend to log and continue past. Always capture the return: `if MyTry() then ... else HandleFailure(GetLastErrorText());`. When the work is transactional — writes that must either fully apply or fully revert — use `Codeunit.Run` instead. The two primitives solve different problems: one catches errors, the other bounds a rollback scope. + +Use `[TryFunction]` sparingly. Each caught error writes to the session-wide `GetLastErrorText` and `GetLastErrorCallStack` buffers, and every subsequent catch overwrites the earlier state — a helper that reads `GetLastErrorText` later may see a different error than the one it intended to inspect. Prefer explicit checks (non-throwing predicates, guard conditions, upfront validation) for operations with predictable failure modes; reserve `[TryFunction]` for genuinely unpredictable failures such as network calls, third-party interop, or evaluation of user-supplied expressions. When you do catch, read `GetLastErrorText` immediately after the failed call, and call `ClearLastError` before the call if an earlier catch in the same scope could have left state behind — per the platform reference, "If you call the GetLastErrorText method immediately after you call the ClearLastError method, then an empty string is returned." + +See sample: `use-tryfunction-for-error-catching-not-rollback.good.al`. + +## Anti Pattern + +Wrapping database writes in `[TryFunction]` expecting the writes to roll back when the method errors. They do not: the writes that succeeded before the error remain, the caller receives `false`, and the corrupted-state bug surfaces in production. A related anti-pattern is calling a try function without capturing the return (`DoTry();`), which silently strips the error-catching behavior and lets the error propagate — the code looks defensive but behaves identically to an unwrapped call. A third is defensive sprinkling: wrapping every operation that *could* theoretically error in `[TryFunction]` on the theory that catching is always safer than propagating. Each extra catch pollutes the shared error buffer and makes the diagnostic signal harder to find when something real does fail. + +See sample: `use-tryfunction-for-error-catching-not-rollback.bad.al`. diff --git a/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.bad.al b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.bad.al new file mode 100644 index 0000000..c3b796b --- /dev/null +++ b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.bad.al @@ -0,0 +1,26 @@ +codeunit 50151 "Sec Sample CommitBeh Bad" +{ + [IntegrationEvent(true, false)] + procedure OnBeforeApplyingDiscount(var Customer: Record Customer) + begin + end; + + procedure ApplyDiscount(var Customer: Record Customer) + begin + Customer."Customer Price Group" := 'VIP'; + Customer.Modify(true); + OnBeforeApplyingDiscount(Customer); + if Customer."Credit Limit (LCY)" <= 0 then + Error('Customer %1 not eligible', Customer."No."); + Commit(); + end; +} + +codeunit 50152 "Sec Sample CommitBeh Bad Sub" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sec Sample CommitBeh Bad", 'OnBeforeApplyingDiscount', '', true, true)] + local procedure NotifyOther(var Customer: Record Customer) + begin + Commit(); + end; +} diff --git a/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.good.al b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.good.al new file mode 100644 index 0000000..0204d12 --- /dev/null +++ b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.good.al @@ -0,0 +1,27 @@ +codeunit 50149 "Sec Sample CommitBeh Good" +{ + [CommitBehavior(CommitBehavior::Ignore)] + [IntegrationEvent(true, false)] + procedure OnBeforeApplyingDiscount(var Customer: Record Customer) + begin + end; + + procedure ApplyDiscount(var Customer: Record Customer) + begin + Customer."Customer Price Group" := 'VIP'; + Customer.Modify(true); + OnBeforeApplyingDiscount(Customer); + if Customer."Credit Limit (LCY)" <= 0 then + Error('Customer %1 not eligible', Customer."No."); + Commit(); + end; +} + +codeunit 50150 "Sec Sample CommitBeh Good Sub" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sec Sample CommitBeh Good", 'OnBeforeApplyingDiscount', '', true, true)] + local procedure NotifyOther(var Customer: Record Customer) + begin + Commit(); + end; +} diff --git a/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.md b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.md new file mode 100644 index 0000000..b7a4ca7 --- /dev/null +++ b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: security +keywords: [commit-behavior, attribute, integration-event, subscriber, commit, atomic] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use [CommitBehavior] to protect an atomic operation from third-party commits + +## Description + +`[CommitBehavior(CommitBehavior::Ignore)]` and `[CommitBehavior(CommitBehavior::Error)]` are method-level attributes that restrict what an explicit `Commit()` does inside the annotated method's scope: `Ignore` silently discards the call; `Error` raises a runtime error. The behavior only lasts for that method's activation — it reverts on method exit whether the method succeeded or errored. The attribute only tightens, never loosens: a parent method running under `Error` overrides any attempt to declare `Ignore` on a nested method. The primary use case is protecting an atomic publisher method — typically an `IntegrationEvent` — from `Commit()` calls in subscribers written by third parties: "you can protect your code from commits happening in event subscriber code; typically written by a third party." The attribute applies to explicit commits only; it does not affect the implicit commit performed by `Codeunit.Run` (see `codeunit-run-requires-prior-commit-inside-transaction.md`). It combines with `[TryFunction]` — a single method may carry both attributes, and each governs its own dimension: `[CommitBehavior]` the commit policy, `[TryFunction]` the error-propagation policy (see `use-tryfunction-for-error-catching-not-rollback.md`). + +## Best Practice + +Annotate publisher methods whose transactional guarantees must survive extension code. The attribute is a selective guard, not a convention: most `IntegrationEvent` publishers do not need it. Events that fire from a standalone query, events fired after the publisher has already committed, informational hooks, and notification-style events are unaffected by subscriber commits. Reach for the attribute only when the publisher has uncommitted writes at the moment of firing and a premature inner commit would persist inconsistent state. Prefer `Ignore` over `Error` when the intent is "silently nullify" — an `Error` from an extension's commit would surface as a subscriber-authored dialog rather than a publisher-defined failure mode. Pair the attribute with the actual atomic-boundary logic in the publisher (validate, then `Commit` on success); a subscriber's suppressed commit remains a no-op regardless of how the publisher completes. + +See sample: `commitbehavior-attribute-scopes-explicit-commits.good.al`. + +## Anti Pattern + +Publishing an `IntegrationEvent` from inside an atomic operation without `[CommitBehavior(CommitBehavior::Ignore)]`. A third-party subscriber that calls `Commit()` — intentionally or by accident — persists the publisher's partial state, defeating any rollback the publisher would have performed on a later validation failure. Another anti-pattern is placing the attribute on a wrapper method and calling a nested `Codeunit.Run` that writes, expecting the attribute to suppress the implicit commit: it does not. The mirror-image anti-pattern is applying the attribute reflexively to every `IntegrationEvent` regardless of context — events that fire outside an atomic sequence gain nothing from the protection, and adding it everywhere clutters the review surface and masks the publishers that genuinely need it. + +See sample: `commitbehavior-attribute-scopes-explicit-commits.bad.al`. diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al new file mode 100644 index 0000000..c30ae3b --- /dev/null +++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al @@ -0,0 +1,10 @@ +codeunit 50154 "Test Sample TransModel Bad" +{ + Subtype = Test; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure TestPostingRoutineAutoRollback() + begin + end; +} diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al new file mode 100644 index 0000000..f977a94 --- /dev/null +++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al @@ -0,0 +1,21 @@ +codeunit 50153 "Test Sample TransModel Good" +{ + Subtype = Test; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure TestLogicThatDoesNotCommit() + var + Customer: Record Customer; + begin + Customer.Init(); + Customer."No." := 'T-001'; + Customer.Insert(true); + end; + + [Test] + [TransactionModel(TransactionModel::AutoCommit)] + procedure TestLogicThatCommitsInternally() + begin + end; +} diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md new file mode 100644 index 0000000..084fccd --- /dev/null +++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: testing +keywords: [transactionmodel, attribute, test, autorollback, autocommit, testisolation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Match TransactionModel to the commit behavior of the code under test + +## Description + +`[TransactionModel(...)]` declares how a test method interacts with the database's write transaction. The attribute applies only to methods inside a codeunit with `SubType = Test` and takes one of three values: `AutoRollback`, `AutoCommit`, or `None`. The choice must match the code being exercised — in particular, whether that code calls `Commit()`. Per the platform reference, "if the code that you test includes calls to the COMMIT Method, then set the TransactionModel property on the test method to AutoCommit." Applying `AutoRollback` to a test that drives code which calls `Commit` produces a runtime error on the first Commit, not a meaningful assertion failure — the test does not complete, and the reviewer sees an infrastructure error instead of a business-logic verdict. + +## Best Practice + +Default to `AutoRollback`: it opens a write transaction at the start of the test, runs the test body, and rolls back at the end, leaving the database in its original state. Pick `AutoCommit` only when the code under test genuinely calls `Commit` — posting routines, job-queue handlers, integration flows — and pair that test's codeunit with a `TestIsolation`-enabled test runner so committed changes are reverted at a higher scope. Pick `None` only for read-only tests or tests that drive UI code without writing from the test method itself, for example tests that validate calculation formulas or read-only projections. + +See sample: `transactionmodel-attribute-governs-test-transactions.good.al`. + +## Anti Pattern + +Applying `AutoRollback` to every test method without checking whether the tested business logic calls `Commit`. The test throws at the first Commit, leaving no verdict on the behavior it intended to verify; in a CI run this looks like a flake or a setup bug, not a specification mismatch. The mirror-image anti-pattern is defaulting to `AutoCommit` across the suite "to avoid the error" — without a `TestIsolation` runner this permanently dirties the test database between runs and produces order-dependent test outcomes. + +See sample: `transactionmodel-attribute-governs-test-transactions.bad.al`. From dc14e7bb2a3f0115f23c5d01a808dc690696fbc4 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Fri, 24 Apr 2026 10:36:08 +0200 Subject: [PATCH 11/15] Update 6 knowledge articles to align with revised instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - performance/use-setloadfields-for-partial-records: Clarify that filter-only fields (SetRange/SetFilter) do not need to be listed in SetLoadFields — the DB resolves them via the index without hydrating the value into AL memory. - performance/avoid-calcfields-in-loops: Add explicit exception for OnAfterGetRecord and OnValidate triggers, which are platform-managed and not developer-authored loops. - performance/split-read-only-and-write-paths-to-avoid-locktable: Add ReadIsolation as the primary recommendation for read-only paths; LockTable reserved for confirmed write paths only. - performance/prefer-direct-record-over-recordref: Scope the finding to hot unbounded loops (10k+ rows) over ledger-entry-scale tables; RecordRef in bounded/admin/setup contexts is not a concern. - upgrade/enum-changes-must-be-additive-at-the-end: Replace direct ObsoleteState = Removed guidance with the two-stage workflow (Pending first, Removed later); reference use-obsolete-pending-before-removed. - upgrade/use-datatransfer-for-large-dataset-initialization: Add the >300,000 records threshold as the concrete trigger for requiring DataTransfer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../knowledge/performance/avoid-calcfields-in-loops.md | 2 ++ .../performance/prefer-direct-record-over-recordref.md | 2 ++ ...split-read-only-and-write-paths-to-avoid-locktable.md | 4 +++- .../performance/use-setloadfields-for-partial-records.md | 4 +++- .../upgrade/enum-changes-must-be-additive-at-the-end.md | 9 ++++++++- .../use-datatransfer-for-large-dataset-initialization.md | 4 +++- 6 files changed, 21 insertions(+), 4 deletions(-) diff --git a/microsoft/knowledge/performance/avoid-calcfields-in-loops.md b/microsoft/knowledge/performance/avoid-calcfields-in-loops.md index 4a94f47..67fb793 100644 --- a/microsoft/knowledge/performance/avoid-calcfields-in-loops.md +++ b/microsoft/knowledge/performance/avoid-calcfields-in-loops.md @@ -17,6 +17,8 @@ CalcFields evaluates one or more FlowFields for the current record by issuing a Move CalcFields out of the iteration. If the total is what you need, use CalcSums on the filtered parent set. If row-by-row FlowField values are needed, reshape the computation so the aggregate runs once — for example by joining against a temporary table populated in a single batched query. +**Acceptable exceptions:** CalcFields inside an `OnAfterGetRecord` page trigger is the standard pattern for displaying computed FlowField values — the platform calls this trigger once per row and it is not a developer-authored loop. Similarly, CalcFields inside an `OnValidate` field trigger fires at most once per user action and is acceptable. The concern is only developer-written `FindSet … repeat … until Next() = 0` loops. + See sample: `avoid-calcfields-in-loops.good.al`. ## Anti Pattern diff --git a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md index f9b76b1..00ca392 100644 --- a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md +++ b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md @@ -19,6 +19,8 @@ RecordRef and FieldRef are the platform's reflection API: they work across table Use Record variables for code paths that target a known table. Reach for RecordRef and FieldRef only when the table is genuinely dynamic (generic export/import, field-agnostic utilities, cross-table integrations). +Only flag RecordRef usage as a performance concern when it appears inside a **hot, unbounded loop** — typically iterating over ledger-entry-scale tables (10,000+ rows) — where a strongly-typed Record alternative exists. RecordRef in bounded contexts, one-off operations, admin tools, setup helpers, or wizard code is not a performance concern and should not be flagged. + See sample: `prefer-direct-record-over-recordref.good.al`. ## Anti Pattern diff --git a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md index 6158f84..da729f0 100644 --- a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md +++ b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md @@ -15,7 +15,9 @@ LockTable takes an exclusive write lock on the affected table for the remainder ## Best Practice -Factor the helper so readers return immediately without a lock and only writers reach the LockTable call. A common pattern: attempt `Rec.Get()` first; if it returns the row, exit with the value; otherwise LockTable and proceed with the Insert. Document the pattern in a comment on the helper so callers understand why the LockTable is inside a branch. +For paths that are read-only, prefer `ReadIsolation` over `LockTable`. Setting `Rec.ReadIsolation := IsolationLevel::ReadCommitted` on a record variable gives fine-grained, per-instance control over the isolation level without taking an update lock on the table for the rest of the transaction. Use `LockTable` only for paths that genuinely write to the table. + +For helpers that may or may not modify records, factor the code so readers return immediately without a lock and only writers reach the LockTable call. A common pattern: attempt `Rec.Get()` first; if it returns the row, exit with the value; otherwise LockTable and proceed with the Insert. Document the pattern in a comment on the helper so callers understand why the LockTable is inside a branch. See sample: `split-read-only-and-write-paths-to-avoid-locktable.good.al`. diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md index 3ee35d4..b7ab187 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -15,7 +15,9 @@ SetLoadFields instructs the platform to hydrate only the listed fields on a reco ## Best Practice -Call SetLoadFields before FindSet, FindFirst, or Get whenever the code path only reads a subset of fields. List every field that is read during the operation, including fields used in filters, calculations, and downstream function calls. Omitting a field that is later accessed triggers a second round-trip. +Call SetLoadFields before FindSet, FindFirst, or Get whenever the code path only reads a subset of fields. List every field that is read or written during the operation, including fields used in calculations and downstream function calls. Omitting a field that is later accessed triggers a second round-trip. + +Fields that appear **only** in SetRange or SetFilter calls do not need to be included — the database resolves the filter using the index without hydrating the value into AL memory. Including filter-only fields wastes bandwidth and is not required. See sample: `use-setloadfields-for-partial-records.good.al`. diff --git a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md b/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md index 1dcba69..5f2ef7e 100644 --- a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md +++ b/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md @@ -15,7 +15,14 @@ AL enums store their ordinal on disk. Inserting a new value in the middle of an ## Best Practice -Append new enum values at the end, taking the next free ordinal. When a value must be retired, mark it with `ObsoleteState = Removed`, `ObsoleteReason`, and `ObsoleteTag` so tooling and downstream code can detect the deprecation; do not reclaim the ordinal. Renaming the caption on an existing ordinal is fine. +Append new enum values at the end, taking the next free ordinal. Renaming the caption on an existing ordinal is fine. + +When a value must be retired, follow the two-stage obsoletion workflow: + +1. **First release:** Mark the value with `ObsoleteState = Pending`, `ObsoleteReason`, and `ObsoleteTag`. This gives callers at least one release cycle to migrate. +2. **Later release:** Advance to `ObsoleteState = Removed` once all callers have been updated. + +Never skip straight to `ObsoleteState = Removed` without first going through `Pending` — doing so removes the warning cycle that callers depend on. Do not reclaim the ordinal in either stage. See also: `use-obsolete-pending-before-removed.md`. See sample: `enum-changes-must-be-additive-at-the-end.good.al`. diff --git a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md index 7a9afc9..ba14648 100644 --- a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md +++ b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md @@ -15,7 +15,9 @@ An upgrade that populates a new field on millions of existing rows with a FindSe ## Best Practice -Use DataTransfer for field-default initialization on existing tables, especially when the target is a ledger-entry or document-line table. Set tables, add source filters, add constant values, call CopyFields, clear, and repeat for additional slices. When trigger or subscriber behaviour is required, do that work separately against a filtered result set so the bulk update remains set-based. +Use DataTransfer when initializing a new field on an existing table that **can contain more than 300,000 records**, or whenever a new field is added to an existing table and the initialization must run across all existing rows. Tables in the ledger-entry and document-line category reliably exceed this threshold; treat them as requiring DataTransfer by default. + +Set tables, add source filters, add constant values, call CopyFields, clear, and repeat for additional slices. When trigger or subscriber behaviour is required, do that work separately against a filtered result set so the bulk update remains set-based. See sample: `use-datatransfer-for-large-dataset-initialization.good.al`. From 800e266bfee28a552174e9e083226ad001f14f60 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Fri, 24 Apr 2026 10:38:28 +0200 Subject: [PATCH 12/15] Add seven performance knowledge articles from BC developer guidance Cover non-obvious platform behaviors a capable LLM reliably gets wrong: hidden FlowFields still calculate, LockTable scopes to the whole table, query objects bypass the primary-key cache, table-event subscribers disable bulk ModifyAll/DeleteAll, Blob fields are uncached, OnCompanyOpen subscribers block every session creation, and the test framework disables bulk insert mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...blob-fields-are-not-cached-prefer-media.md | 26 +++++++++++++++++++ ...den-flowfields-still-calculate-on-pages.md | 24 +++++++++++++++++ ...p-oncompanyopen-subscribers-lightweight.md | 26 +++++++++++++++++++ ...e-applies-to-whole-table-in-transaction.md | 24 +++++++++++++++++ .../query-objects-bypass-primary-key-cache.md | 26 +++++++++++++++++++ ...rs-disable-bulk-modifyall-and-deleteall.md | 24 +++++++++++++++++ ...framework-to-measure-insert-performance.md | 26 +++++++++++++++++++ 7 files changed, 176 insertions(+) create mode 100644 microsoft/knowledge/performance/blob-fields-are-not-cached-prefer-media.md create mode 100644 microsoft/knowledge/performance/hidden-flowfields-still-calculate-on-pages.md create mode 100644 microsoft/knowledge/performance/keep-oncompanyopen-subscribers-lightweight.md create mode 100644 microsoft/knowledge/performance/locktable-applies-to-whole-table-in-transaction.md create mode 100644 microsoft/knowledge/performance/query-objects-bypass-primary-key-cache.md create mode 100644 microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md create mode 100644 microsoft/knowledge/performance/uninstall-test-framework-to-measure-insert-performance.md diff --git a/microsoft/knowledge/performance/blob-fields-are-not-cached-prefer-media.md b/microsoft/knowledge/performance/blob-fields-are-not-cached-prefer-media.md new file mode 100644 index 0000000..75b4347 --- /dev/null +++ b/microsoft/knowledge/performance/blob-fields-are-not-cached-prefer-media.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [blob, media, mediaset, cache, image, thumbnail] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Blob fields are never cached — prefer Media or MediaSet for images + +## Description + +`Blob` field contents are not cached by the Business Central server or the client. Every read re-fetches the full payload from the database, even when the same blob was read moments earlier in the same session. For images displayed on a page, this turns into a database round-trip per render. + +`Media` and `MediaSet` are purpose-built for this and behave differently in two ways that matter for performance. First, they are cached on the client, so subsequent renders of the same image do not re-hit the database. Second, the platform generates a thumbnail when the data is saved, so a list or card page can show the thumbnail immediately and lazy-load the full-resolution image — typically via a Page Background Task — only when needed. + +`Blob` remains appropriate for non-image binary data that is written once and rarely read, or for data the platform does not need to render. For any field that is displayed repeatedly — profile pictures, item images, logos on documents — `Media` or `MediaSet` is the default. + +## Best Practice + +Store images in `Media` or `MediaSet` fields. Bind the thumbnail to the page; load full-resolution data asynchronously when the user opens the full view. Reserve `Blob` for opaque payloads that are not rendered in the UI. + +## Anti Pattern + +An Item Image field defined as `Blob` and shown directly on a list page. Every scroll re-fetches every image from SQL, the list page load time scales with row count and image size, and no client-side caching mitigates the cost. diff --git a/microsoft/knowledge/performance/hidden-flowfields-still-calculate-on-pages.md b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-on-pages.md new file mode 100644 index 0000000..184e363 --- /dev/null +++ b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-on-pages.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [flowfield, visible, enabled, page, calcfields, feature-management] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Hidden FlowFields still calculate on pages + +## Description + +Setting `Visible = false` or `Enabled = false` on a FlowField hides the control but does not suppress the calculation. The server still runs the underlying CalcFields for every row the page renders. On a list page over a large table, the invisible column keeps consuming the same SQL as a visible one — the hiding is cosmetic only, and a diff that "turns off" an expensive FlowField by flipping `Visible` fixes nothing on the server. + +There are two correct remedies. The durable one is to remove the FlowField from the page or page-extension definition entirely — property toggles are not enough. The environment-level one, available where supported, is the **Calculate only visible FlowFields** feature in Feature Management; when enabled, the AL runtime skips calculation for non-visible FlowFields on pages. The feature is opt-in and administrator-controlled, so code cannot assume it is active. + +## Best Practice + +Remove unused or hidden FlowFields from the page or page extension. If the field is needed for some users but expensive for others, factor into a dedicated page variant rather than hiding it in place. Do not rely on `Visible = false` as a performance fix unless the tenant has enabled the Calculate only visible FlowFields feature and that assumption is acceptable. + +## Anti Pattern + +A performance PR that sets `Visible = false` on an expensive FlowField on a list page and claims the column no longer impacts load time. The control disappears from the UI, the CalcFields still runs for every row, and the list page stays slow. diff --git a/microsoft/knowledge/performance/keep-oncompanyopen-subscribers-lightweight.md b/microsoft/knowledge/performance/keep-oncompanyopen-subscribers-lightweight.md new file mode 100644 index 0000000..16a2061 --- /dev/null +++ b/microsoft/knowledge/performance/keep-oncompanyopen-subscribers-lightweight.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [oncompanyopen, oncompanyopencompleted, session, sign-in, subscriber, startup] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep OnCompanyOpen and OnCompanyOpenCompleted subscribers lightweight + +## Description + +`OnCompanyOpen` and `OnCompanyOpenCompleted` are raised every time a session is created — not only for interactive sign-ins, but also for every web service call, every job queue entry, every scheduled task, and every page background task. The session cannot run any AL code until every subscriber on these events has finished. Interactive users see a spinner; web service callers see elevated response times; background sessions sit idle waiting to start. + +Anything expensive in these subscribers is paid per session across the whole tenant. The two patterns that typically cause production incidents are outgoing HTTP calls to external services — which block AL execution until they complete (or time out) — and long-running SQL over large tables. An external service that is slow or unreachable turns into a tenant-wide sign-in outage, not a degraded feature. + +The code often looks harmless in review: a telemetry ping, a configuration refresh, a "just make sure the setup record exists" Get-or-Insert. Multiplied by session creations per minute, each of these becomes the critical path of sign-in. + +## Best Practice + +Keep `OnCompanyOpen` and `OnCompanyOpenCompleted` subscribers short and in-memory. Defer work that touches external services or large tables to a Page Background Task, a job queue entry, or a lazy first-use path. If an outgoing HTTP call in startup is truly unavoidable, set an aggressive timeout so a failing endpoint cannot stall session creation. + +## Anti Pattern + +An `OnCompanyOpen` subscriber that calls an external licensing API over HttpClient without a tight timeout. When the endpoint is slow, every new session in the tenant — UI, API, background — waits on the HTTP call before it can run any AL. diff --git a/microsoft/knowledge/performance/locktable-applies-to-whole-table-in-transaction.md b/microsoft/knowledge/performance/locktable-applies-to-whole-table-in-transaction.md new file mode 100644 index 0000000..d6bf687 --- /dev/null +++ b/microsoft/knowledge/performance/locktable-applies-to-whole-table-in-transaction.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [locktable, updlock, transaction, contention, scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# LockTable applies to the whole table for the rest of the transaction + +## Description + +`Record.LockTable` is commonly read as "lock this record variable", but it does not work that way. The call applies `WITH (UPDLOCK)` to every subsequent read against the underlying table in the current transaction, regardless of which record variable issues the read. If `ItemA.LockTable` runs, then an unrelated `ItemB` variable on `Item`, a `FindSet` from a helper codeunit on `Item`, and any nested code that reads `Item` all acquire UPDLOCK until the transaction commits. + +The consequence is that calling LockTable early in a transaction — for example at the top of a routine "to be safe" — upgrades every read of that table for the remainder of the transaction to a writer-blocking lock. Contention scales with transaction length, not with how many writes the code actually performs. A LockTable deep in a call graph can silently serialize readers that never touch the LockTable-ing variable. + +## Best Practice + +Defer `LockTable` as late as possible and place it as close to the actual modification as you can. Keep transactions short so the UPDLOCK window is narrow. Do not add LockTable preemptively to "protect" a read that is not part of a read-modify-write sequence — the correct tool for read consistency is an isolation level (see Record.ReadIsolation), not a write lock. + +## Anti Pattern + +A procedure that calls `Rec.LockTable()` at the start "before doing anything" and then performs a long read-heavy validation before the eventual Modify. Every read in the validation now takes UPDLOCK on the whole table, and every other session that tries to read the same table waits on this transaction. diff --git a/microsoft/knowledge/performance/query-objects-bypass-primary-key-cache.md b/microsoft/knowledge/performance/query-objects-bypass-primary-key-cache.md new file mode 100644 index 0000000..3c05629 --- /dev/null +++ b/microsoft/knowledge/performance/query-objects-bypass-primary-key-cache.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [query, cache, primary-key-cache, record-api, sql] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Query objects bypass the primary-key cache and always hit SQL + +## Description + +The Record API reuses a server-side primary-key cache: repeated reads of the same rows within a session or request can be served from memory without going to SQL. Query objects do not participate in that cache. Every execution of a query goes to the database, even when the same rows were just read through a Record variable in the same transaction. + +This inverts the usual intuition that queries are always faster than record loops. Queries win when they exploit a covering index, aggregate, or join multiple tables in SQL that AL would otherwise loop. They lose when the data is small, already cached, or read repeatedly in a short window — the per-call SQL round-trip dominates. + +Query objects also cannot write, cannot be backed by a page, and do not see the records a temp-table-backed AL flow has inserted but not committed. Choose them for set-based reads over indexed data, not as a generic replacement for the Record API. + +## Best Practice + +Use a query object when the shape of the work is genuinely set-based: aggregation, multi-table join, or a large read that benefits from a covering index. For hot single-record or small-result reads — especially lookups that will repeat in the same request — prefer the Record API so the primary-key cache does its job. + +## Anti Pattern + +Replacing a `Get` or a short filtered `FindSet` inside a frequently-called helper with a query object "for performance". Every caller now pays a SQL round-trip that the Record API cache had been absorbing, and the helper gets slower under load, not faster. diff --git a/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md b/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md new file mode 100644 index 0000000..1674838 --- /dev/null +++ b/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [event, subscriber, modifyall, deleteall, bulk, row-by-row] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Table event subscribers force ModifyAll and DeleteAll to run row-by-row + +## Description + +`ModifyAll` and `DeleteAll` normally compile to a single set-based SQL UPDATE or DELETE. That optimization is conditional: if any subscriber is bound to the table's modify or delete events — `OnBeforeModifyEvent`, `OnAfterModifyEvent`, `OnBeforeDeleteEvent`, `OnAfterDeleteEvent`, and their Rec counterparts — the server must invoke AL per affected row so the subscriber sees each record. The operation falls back to a row-by-row loop, one SQL statement per row, inside the same transaction. + +The slowdown is invisible in the caller's source: the call site still reads as a bulk operation. It only shows up under load, and adding an apparently cheap subscriber (even an empty one, or one that guards on a condition and returns) is enough to trigger the fallback for every caller of ModifyAll/DeleteAll on that table across the system. Central tables — Item Ledger Entry, G/L Entry, Sales Line — are the worst places to attach such subscribers because every extension's bulk operation pays the cost. + +## Best Practice + +Before subscribing to a table's modify or delete events, consider whether the logic can live elsewhere — on the triggering action, on a specific OnValidate, or on a business-event publisher. If the subscriber is unavoidable, scope it as narrowly as possible and document that it forces row-by-row execution so future maintainers understand the cost. Watch PRs that add such subscribers to heavily-modified tables. + +## Anti Pattern + +An empty or nearly-empty `OnAfterModifyEvent` subscriber on `Sales Line` added as a placeholder for future integration. Every `ModifyAll` on `Sales Line` — in the base app, in every extension, in every tenant — now runs one SQL UPDATE per row. diff --git a/microsoft/knowledge/performance/uninstall-test-framework-to-measure-insert-performance.md b/microsoft/knowledge/performance/uninstall-test-framework-to-measure-insert-performance.md new file mode 100644 index 0000000..ca4d81c --- /dev/null +++ b/microsoft/knowledge/performance/uninstall-test-framework-to-measure-insert-performance.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [test-framework, bulk-insert, performance-test, benchmark, insert] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Uninstall the test framework to measure insert performance + +## Description + +Business Central's server uses a bulk insert optimization that batches multiple row inserts into a single SQL round-trip when conditions allow. When the test framework is installed on the environment, that optimization is disabled — inserts fall back to one SQL statement per row. The behavior is a side-effect of how the test framework instruments AL execution and applies whether or not any test is actually running. + +For functional tests this is invisible; for performance measurement it is catastrophic. A benchmark that inserts ten thousand rows with the test framework present reports a number that has nothing to do with production, because production will not run in row-by-row mode. Treating the measurement as a real baseline produces conclusions that are wrong by a large constant factor. + +The same caveat applies to Update and Delete paths where bulk optimizations exist — the test framework's presence suppresses them. + +## Best Practice + +Before running any insert, update, or delete throughput benchmark — whether via the Performance Toolkit, a hand-rolled harness, or `SessionInformation` assertions — uninstall the test framework from the target environment. Re-install it only for functional test runs. Document this step in the benchmark procedure so future measurements are comparable. + +## Anti Pattern + +A performance regression report comparing two builds on a sandbox that has the test framework installed. Both numbers are row-by-row timings; the ratio between them may be meaningful, but neither number reflects production, and any absolute throughput claim derived from the run is wrong. From 5bcdc55df93867a5d501c794757ce7cfe9884d8f Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 5 May 2026 14:08:32 +0200 Subject: [PATCH 13/15] Sync knowledge articles with review agent instructions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ify-every-field-with-dataclassification.md | 6 ++-- ...keep-event-subscribers-lightweight.good.al | 20 +++++++++++++ .../keep-event-subscribers-lightweight.md | 4 ++- ...and-write-paths-to-avoid-locktable.good.al | 3 +- ...only-and-write-paths-to-avoid-locktable.md | 4 +-- ...rs-disable-bulk-modifyall-and-deleteall.md | 6 ++-- ...nary-for-temporary-identity-lookups.bad.al | 16 ++++++++++ ...ary-for-temporary-identity-lookups.good.al | 13 +++++++++ ...ctionary-for-temporary-identity-lookups.md | 26 +++++++++++++++++ .../use-findset-readonly-by-default.md | 4 +-- .../use-setloadfields-for-partial-records.md | 2 +- ...extbuilder-for-loop-string-assembly.bad.al | 14 +++++++++ ...xtbuilder-for-loop-string-assembly.good.al | 14 +++++++++ ...se-textbuilder-for-loop-string-assembly.md | 26 +++++++++++++++++ ...ssify-data-at-migration-destination.bad.al | 14 +++++++++ ...sify-data-at-migration-destination.good.al | 14 +++++++++ .../classify-data-at-migration-destination.md | 26 +++++++++++++++++ ...r-is-logged-to-telemetry-message-is-not.md | 4 +-- ...-out-of-featuretelemetry-dimensions.bad.al | 14 +++++++++ ...out-of-featuretelemetry-dimensions.good.al | 13 +++++++++ ...data-out-of-featuretelemetry-dimensions.md | 26 +++++++++++++++++ .../resolve-tobeclassified-before-release.md | 22 ++++++++++++++ ...aclassification-on-every-telemetry-call.md | 2 +- ...d-breaks-error-telemetry-classification.md | 4 +-- ...pose-sensitive-data-in-event-publishers.md | 4 +-- ...p-recordref-open-callers-non-public.bad.al | 12 ++++++++ ...-recordref-open-callers-non-public.good.al | 12 ++++++++ .../keep-recordref-open-callers-non-public.md | 26 +++++++++++++++++ ...rage-for-module-and-company-secrets.bad.al | 1 + ...age-for-module-and-company-secrets.good.al | 4 +-- ...-storage-for-module-and-company-secrets.md | 4 +-- ...-nondebuggable-when-parsing-secrets.bad.al | 5 ++++ ...nondebuggable-when-parsing-secrets.good.al | 6 ++++ .../use-nondebuggable-when-parsing-secrets.md | 6 ++-- ...configurable-urls-before-http-calls.bad.al | 10 +++++++ ...onfigurable-urls-before-http-calls.good.al | 17 +++++++++++ ...ser-configurable-urls-before-http-calls.md | 26 +++++++++++++++++ .../keep-captions-on-editable-fields.bad.al | 14 +++++++++ .../keep-captions-on-editable-fields.good.al | 21 ++++++++++++++ .../ui/keep-captions-on-editable-fields.md | 26 +++++++++++++++++ ...y-review-control-addin-ui-accessibility.md | 22 ++++++++++++++ ...de-text-meaning-for-semantic-styles.bad.al | 19 ++++++++++++ ...e-text-meaning-for-semantic-styles.good.al | 19 ++++++++++++ ...rovide-text-meaning-for-semantic-styles.md | 26 +++++++++++++++++ ...rid-data-table-pattern-consistently.bad.al | 24 +++++++++++++++ ...id-data-table-pattern-consistently.good.al | 29 +++++++++++++++++++ ...se-grid-data-table-pattern-consistently.md | 26 +++++++++++++++++ ...xisting-data-before-key-or-type-changes.md | 22 ++++++++++++++ ...ake-external-calls-in-upgrade-codeunits.md | 2 +- ...n-codeunits-from-standard-upgrade-rules.md | 22 ++++++++++++++ ...formance-impacting-upgrade-triggers.bad.al | 13 +++++++++ ...ormance-impacting-upgrade-triggers.good.al | 25 ++++++++++++++++ ...-performance-impacting-upgrade-triggers.md | 26 +++++++++++++++++ ...alue-does-not-populate-existing-records.md | 2 +- .../register-upgrade-tags-with-subscribers.md | 4 +-- ...ansfer-for-large-dataset-initialization.md | 6 ++-- .../use-upgrade-tags-not-version-checks.md | 4 +-- .../skills/review/al-performance-review.md | 2 +- microsoft/skills/review/al-privacy-review.md | 8 ++--- microsoft/skills/review/al-security-review.md | 6 ++-- microsoft/skills/review/al-ui-review.md | 24 +++++++-------- microsoft/skills/review/al-upgrade-review.md | 4 +-- 62 files changed, 768 insertions(+), 58 deletions(-) create mode 100644 microsoft/knowledge/performance/keep-event-subscribers-lightweight.good.al create mode 100644 microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.bad.al create mode 100644 microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.good.al create mode 100644 microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.md create mode 100644 microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.bad.al create mode 100644 microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.good.al create mode 100644 microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.md create mode 100644 microsoft/knowledge/privacy/classify-data-at-migration-destination.bad.al create mode 100644 microsoft/knowledge/privacy/classify-data-at-migration-destination.good.al create mode 100644 microsoft/knowledge/privacy/classify-data-at-migration-destination.md create mode 100644 microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.bad.al create mode 100644 microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.good.al create mode 100644 microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.md create mode 100644 microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md create mode 100644 microsoft/knowledge/security/keep-recordref-open-callers-non-public.bad.al create mode 100644 microsoft/knowledge/security/keep-recordref-open-callers-non-public.good.al create mode 100644 microsoft/knowledge/security/keep-recordref-open-callers-non-public.md create mode 100644 microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.bad.al create mode 100644 microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.good.al create mode 100644 microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.md create mode 100644 microsoft/knowledge/ui/keep-captions-on-editable-fields.bad.al create mode 100644 microsoft/knowledge/ui/keep-captions-on-editable-fields.good.al create mode 100644 microsoft/knowledge/ui/keep-captions-on-editable-fields.md create mode 100644 microsoft/knowledge/ui/manually-review-control-addin-ui-accessibility.md create mode 100644 microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.bad.al create mode 100644 microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.good.al create mode 100644 microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.md create mode 100644 microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.bad.al create mode 100644 microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.good.al create mode 100644 microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.md create mode 100644 microsoft/knowledge/upgrade/assess-existing-data-before-key-or-type-changes.md create mode 100644 microsoft/knowledge/upgrade/exclude-hybrid-migration-codeunits-from-standard-upgrade-rules.md create mode 100644 microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.bad.al create mode 100644 microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.good.al create mode 100644 microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.md diff --git a/community/knowledge/security/classify-every-field-with-dataclassification.md b/community/knowledge/security/classify-every-field-with-dataclassification.md index bca3219..1b854aa 100644 --- a/community/knowledge/security/classify-every-field-with-dataclassification.md +++ b/community/knowledge/security/classify-every-field-with-dataclassification.md @@ -11,16 +11,16 @@ application-area: [all] ## Description -Every field on every AL table and table extension must carry an explicit `DataClassification` property. The value drives GDPR tooling, data-subject requests, retention policies, and audit reporting — all of which rely on the field metadata to know what data to include, anonymize, or delete. A field with no `DataClassification` defaults to `ToBeClassified`, which is a compliance gap, not a neutral state. +Every field on every AL table and table extension must have a resolved `DataClassification` value, either declared directly on the field or inherited from a table-level default. The value drives GDPR tooling, data-subject requests, retention policies, and audit reporting — all of which rely on the field metadata to know what data to include, anonymize, or delete. A field with no field-level property and no table-level default resolves to `ToBeClassified`, which is a compliance gap, not a neutral state. ## Best Practice -Choose the narrowest value that accurately describes the field's content: `EndUserIdentifiableInformation` for data that directly identifies a person, `EndUserPseudonymousIdentifiers` for indirect identifiers, `CustomerContent` for business operational data, `SystemMetadata` for system-generated housekeeping, `AccountData` for tenant/billing, `OrganizationIdentifiableInformation` for organization-level identifiers. When uncertain between two values, pick the stronger protection. +Choose the narrowest value that accurately describes the field's content: `EndUserIdentifiableInformation` for data that directly identifies a person, `EndUserPseudonymousIdentifiers` for indirect identifiers, `CustomerContent` for business operational data, `SystemMetadata` for system-generated housekeeping, `AccountData` for tenant/billing, `OrganizationIdentifiableInformation` for organization-level identifiers. Use a table-level default for homogeneous tables, and override individual fields whose content differs from that default. When uncertain between two values, pick the stronger protection. See sample: `classify-every-field-with-dataclassification.good.al`. ## Anti Pattern -Leaving `DataClassification = ToBeClassified` on a field, or omitting the property entirely (which resolves to the same default). Code in this state fails compliance audits and breaks the subject-access-request and retention tooling that depends on the property being set correctly. +Leaving `DataClassification = ToBeClassified` on a field, omitting classification when the table has no default, or relying on a table-level default that understates a field's actual content. Code in this state fails compliance audits and breaks the subject-access-request and retention tooling that depends on the property being set correctly. See sample: `classify-every-field-with-dataclassification.bad.al`. diff --git a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.good.al b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.good.al new file mode 100644 index 0000000..df25047 --- /dev/null +++ b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.good.al @@ -0,0 +1,20 @@ +codeunit 50930 "Perf Sample Subscriber Good" +{ + [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'No.', false, false)] + local procedure OnAfterValidateSalesLineNo(var Rec: Record "Sales Line") + var + Item: Record Item; + begin + if Rec.Type <> Rec.Type::Item then + exit; + + Item.SetLoadFields("Costing Method"); + if Item.Get(Rec."No.") then + if Item."Costing Method" = Item."Costing Method"::Specific then + UpdateSpecificCostingState(Rec); + end; + + local procedure UpdateSpecificCostingState(var SalesLine: Record "Sales Line") + begin + end; +} diff --git a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md index a1fae2a..4aa59fb 100644 --- a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md +++ b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md @@ -17,7 +17,9 @@ Event subscribers run synchronously on the publisher's thread. If a subscriber d ## Best Practice -Keep subscribers small: guard early with inexpensive checks, defer heavy work to a task queue or a background session, and cache results across invocations when the data is stable. +Keep subscribers small: guard early with inexpensive checks on the publisher record before doing any database work, defer heavy work to a task queue or a background session, and cache results across invocations when the data is stable. In hot events, a cheap `Type`/`Status`/`IsTemporary` exit before a `Get` or `FindFirst` is often the difference between a rare lookup and an N+1 query across every posted line. + +See sample: `keep-event-subscribers-lightweight.good.al`. ## Anti Pattern diff --git a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al index 2dba7e3..1eca2d9 100644 --- a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al +++ b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al @@ -2,7 +2,8 @@ codeunit 51204 "Perf Sample LockTable Good" { procedure GetOrCreate(var AgentStatus: Record "Integer"): Boolean begin - // Read path: no lock. + // Read path: consistent read on this record instance only. + AgentStatus.ReadIsolation := IsolationLevel::ReadCommitted; if AgentStatus.Get(1) then exit(true); diff --git a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md index da729f0..f9dae60 100644 --- a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md +++ b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md @@ -11,11 +11,11 @@ application-area: [all] ## Description -LockTable takes an exclusive write lock on the affected table for the remainder of the transaction. In a helper that is called from many read-only sites and a few write sites, placing LockTable unconditionally at the top serializes every reader on every other reader's lock — the helper becomes a system-wide contention point. The correct shape is a conditional structure: try the read-only path first, and only fall through to LockTable when the code genuinely needs to modify the table. +LockTable causes reads against the table to use update locks for the remainder of the transaction. In a helper that is called from many read-only sites and a few write sites, placing LockTable unconditionally at the top serializes every reader on every other reader's lock — the helper becomes a system-wide contention point. The correct shape is a conditional structure: try the read-only path first, and only fall through to LockTable when the code genuinely needs to modify the table. ## Best Practice -For paths that are read-only, prefer `ReadIsolation` over `LockTable`. Setting `Rec.ReadIsolation := IsolationLevel::ReadCommitted` on a record variable gives fine-grained, per-instance control over the isolation level without taking an update lock on the table for the rest of the transaction. Use `LockTable` only for paths that genuinely write to the table. +For paths that are read-only, prefer `ReadIsolation` over `LockTable`. Setting `Rec.ReadIsolation := IsolationLevel::ReadCommitted` on a record variable gives fine-grained, per-instance control over the isolation level without taking an update lock on the table for the rest of the transaction. Use `ReadCommitted` as the normal read-only choice; move to `RepeatableRead`, `Serializable`, or an update lock only when the code has a concrete consistency invariant that requires it. Use `LockTable` only for paths that genuinely write to the table. For helpers that may or may not modify records, factor the code so readers return immediately without a lock and only writers reach the LockTable call. A common pattern: attempt `Rec.Get()` first; if it returns the row, exit with the value; otherwise LockTable and proceed with the Insert. Document the pattern in a comment on the helper so callers understand why the LockTable is inside a branch. diff --git a/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md b/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md index 1674838..21ce82d 100644 --- a/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md +++ b/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md @@ -11,14 +11,14 @@ application-area: [all] ## Description -`ModifyAll` and `DeleteAll` normally compile to a single set-based SQL UPDATE or DELETE. That optimization is conditional: if any subscriber is bound to the table's modify or delete events — `OnBeforeModifyEvent`, `OnAfterModifyEvent`, `OnBeforeDeleteEvent`, `OnAfterDeleteEvent`, and their Rec counterparts — the server must invoke AL per affected row so the subscriber sees each record. The operation falls back to a row-by-row loop, one SQL statement per row, inside the same transaction. +`ModifyAll` and `DeleteAll` normally compile to a single set-based SQL UPDATE or DELETE. That optimization is conditional: the server falls back to row-by-row execution when it must invoke AL per affected row. Common causes are global table delete triggers, table modify/delete event subscribers, and Media or MediaSet fields added to the table or a table extension. The slowdown is invisible in the caller's source: the call site still reads as a bulk operation. It only shows up under load, and adding an apparently cheap subscriber (even an empty one, or one that guards on a condition and returns) is enough to trigger the fallback for every caller of ModifyAll/DeleteAll on that table across the system. Central tables — Item Ledger Entry, G/L Entry, Sales Line — are the worst places to attach such subscribers because every extension's bulk operation pays the cost. ## Best Practice -Before subscribing to a table's modify or delete events, consider whether the logic can live elsewhere — on the triggering action, on a specific OnValidate, or on a business-event publisher. If the subscriber is unavoidable, scope it as narrowly as possible and document that it forces row-by-row execution so future maintainers understand the cost. Watch PRs that add such subscribers to heavily-modified tables. +Before subscribing to a table's modify or delete events, consider whether the logic can live elsewhere — on the triggering action, on a specific OnValidate, or on a business-event publisher. If the subscriber, global trigger, or Media/MediaSet field is unavoidable, document that the table may no longer support set-based ModifyAll/DeleteAll. When a table has not regressed, prefer a small number of ModifyAll/DeleteAll calls; they are still commonly 10-50x faster than a manual loop. ## Anti Pattern -An empty or nearly-empty `OnAfterModifyEvent` subscriber on `Sales Line` added as a placeholder for future integration. Every `ModifyAll` on `Sales Line` — in the base app, in every extension, in every tenant — now runs one SQL UPDATE per row. +An empty or nearly-empty `OnAfterModifyEvent` subscriber on `Sales Line` added as a placeholder for future integration. Every `ModifyAll` on `Sales Line` — in the base app, in every extension, in every tenant — can now run one SQL UPDATE per row. The same regression can come from a global delete trigger or from adding a Media field to the table. diff --git a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.bad.al b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.bad.al new file mode 100644 index 0000000..1155668 --- /dev/null +++ b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.bad.al @@ -0,0 +1,16 @@ +codeunit 50934 "Perf Sample TempLookup Bad" +{ + procedure MarkSeenCustomers(var SalesLine: Record "Sales Line") + var + TempCustomer: Record Customer temporary; + begin + if SalesLine.FindSet() then + repeat + if not TempCustomer.Get(SalesLine."Sell-to Customer No.") then begin + TempCustomer.Init(); + TempCustomer."No." := SalesLine."Sell-to Customer No."; + TempCustomer.Insert(); + end; + until SalesLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.good.al b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.good.al new file mode 100644 index 0000000..ff6d499 --- /dev/null +++ b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.good.al @@ -0,0 +1,13 @@ +codeunit 50933 "Perf Sample Dictionary Good" +{ + procedure MarkSeenCustomers(var SalesLine: Record "Sales Line") + var + SeenCustomerNos: Dictionary of [Code[20], Boolean]; + begin + if SalesLine.FindSet() then + repeat + if not SeenCustomerNos.ContainsKey(SalesLine."Sell-to Customer No.") then + SeenCustomerNos.Add(SalesLine."Sell-to Customer No.", true); + until SalesLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.md b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.md new file mode 100644 index 0000000..0e2843c --- /dev/null +++ b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [dictionary, temporary-table, lookup, identity, o1] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use Dictionary for temporary identity lookups + +## Description + +A temporary record is useful when code needs record semantics: filters, keys, FlowFields, or table-shaped buffers. When the only operation is "have I seen this key?" or "what value belongs to this key?", a `Dictionary` is the simpler and faster structure. Dictionary lookup is O(1) by key, while a temporary table still pays record and key-management overhead. + +## Best Practice + +Use `Dictionary` for in-memory lookup sets and maps whose keys fit in memory and whose access pattern is by identity. Keep temporary tables for data that needs table APIs, multiple keys, filter expressions, or later processing as records. + +See sample: `use-dictionary-for-temporary-identity-lookups.good.al`. + +## Anti Pattern + +Creating a temporary table solely to call `Get` or `FindFirst` by a single key in a loop. The code looks familiar to AL developers, but it is heavier than the lookup problem requires. + +See sample: `use-dictionary-for-temporary-identity-lookups.bad.al`. diff --git a/microsoft/knowledge/performance/use-findset-readonly-by-default.md b/microsoft/knowledge/performance/use-findset-readonly-by-default.md index 602dba6..6843c91 100644 --- a/microsoft/knowledge/performance/use-findset-readonly-by-default.md +++ b/microsoft/knowledge/performance/use-findset-readonly-by-default.md @@ -11,11 +11,11 @@ application-area: [all] ## Description -FindSet has two modes: FindSet() and FindSet(false) are read-only and take no write lock; FindSet(true) calls LockTable before fetching. Write locks are expensive and hold for the remainder of the transaction, so passing `true` when you do not intend to modify the records increases contention under load. +FindSet has two modes: FindSet() and FindSet(false) are read-only and take no update lock; FindSet(true) sets update-lock read isolation on the record before fetching. Update locks are expensive and hold for the lock scope, so passing `true` when you do not intend to modify the records increases contention under load. ## Best Practice -Call FindSet with no arguments when the loop only reads field values. Pass `true` only when the same loop is expected to call Modify, Delete, or Rename on the record, and the correctness of the operation depends on the table being locked for the full iteration. +Call FindSet with no arguments when the loop only reads field values. Pass `true` only when the same loop is expected to call Modify, Delete, or Rename on the record, and the correctness of the operation depends on the matching rows being locked for the iteration. See sample: `use-findset-readonly-by-default.good.al`. diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md index b7ab187..3acf4f2 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -15,7 +15,7 @@ SetLoadFields instructs the platform to hydrate only the listed fields on a reco ## Best Practice -Call SetLoadFields before FindSet, FindFirst, or Get whenever the code path only reads a subset of fields. List every field that is read or written during the operation, including fields used in calculations and downstream function calls. Omitting a field that is later accessed triggers a second round-trip. +Call SetLoadFields before FindSet, FindFirst, or Get when the table is wide enough to matter (roughly 10+ fields) and the code path reads a small subset (roughly under 60%) across a material number of rows. Short loops over narrow tables usually do not earn the extra coupling; see `skip-setloadfields-on-narrow-tables-and-short-loops` for that exception. List every field that is read or written during the operation, including fields used in calculations and downstream function calls. Omitting a field that is later accessed triggers a second round-trip. Fields that appear **only** in SetRange or SetFilter calls do not need to be included — the database resolves the filter using the index without hydrating the value into AL memory. Including filter-only fields wastes bandwidth and is not required. diff --git a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.bad.al b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.bad.al new file mode 100644 index 0000000..97bf89a --- /dev/null +++ b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.bad.al @@ -0,0 +1,14 @@ +codeunit 50932 "Perf Sample TextConcat Bad" +{ + procedure BuildItemList(var Item: Record Item): Text + var + Result: Text; + begin + if Item.FindSet() then + repeat + Result += StrSubstNo('%1,%2', Item."No.", Item.Description); + until Item.Next() = 0; + + exit(Result); + end; +} diff --git a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.good.al b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.good.al new file mode 100644 index 0000000..417759c --- /dev/null +++ b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.good.al @@ -0,0 +1,14 @@ +codeunit 50931 "Perf Sample TextBuilder Good" +{ + procedure BuildItemList(var Item: Record Item): Text + var + Builder: TextBuilder; + begin + if Item.FindSet() then + repeat + Builder.AppendLine(StrSubstNo('%1,%2', Item."No.", Item.Description)); + until Item.Next() = 0; + + exit(Builder.ToText()); + end; +} diff --git a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.md b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.md new file mode 100644 index 0000000..32080b5 --- /dev/null +++ b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [textbuilder, string-concatenation, loop, text, allocation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use TextBuilder for loop-based string assembly + +## Description + +Repeated `Text := Text + ...` concatenation inside a loop reallocates and copies the growing string on every iteration. In AL, `TextBuilder` is the platform type for constructing larger text payloads incrementally. `StrSubstNo` remains appropriate for formatting one message; TextBuilder is for many appends, especially inside loops. + +## Best Practice + +Use `TextBuilder.Append` or `AppendLine` when assembling CSV rows, log payloads, JSON-ish diagnostic text, or other multi-line strings from repeated loop iterations. Convert to Text once, after the loop, with `ToText()`. + +See sample: `use-textbuilder-for-loop-string-assembly.good.al`. + +## Anti Pattern + +Appending to the same Text variable on every iteration of a large loop. Each append copies the accumulated prefix again, so the cost grows with both row count and final string length. + +See sample: `use-textbuilder-for-loop-string-assembly.bad.al`. diff --git a/microsoft/knowledge/privacy/classify-data-at-migration-destination.bad.al b/microsoft/knowledge/privacy/classify-data-at-migration-destination.bad.al new file mode 100644 index 0000000..4300fd9 --- /dev/null +++ b/microsoft/knowledge/privacy/classify-data-at-migration-destination.bad.al @@ -0,0 +1,14 @@ +table 50936 "Migrated Employee" +{ + fields + { + field(1; "Employee No."; Code[20]) + { + DataClassification = ToBeClassified; + } + field(2; "Tax Identification No."; Text[30]) + { + DataClassification = SystemMetadata; + } + } +} diff --git a/microsoft/knowledge/privacy/classify-data-at-migration-destination.good.al b/microsoft/knowledge/privacy/classify-data-at-migration-destination.good.al new file mode 100644 index 0000000..44204a5 --- /dev/null +++ b/microsoft/knowledge/privacy/classify-data-at-migration-destination.good.al @@ -0,0 +1,14 @@ +table 50935 "Migrated Employee" +{ + fields + { + field(1; "Employee No."; Code[20]) + { + DataClassification = EndUserPseudonymousIdentifiers; + } + field(2; "Tax Identification No."; Text[30]) + { + DataClassification = EndUserIdentifiableInformation; + } + } +} diff --git a/microsoft/knowledge/privacy/classify-data-at-migration-destination.md b/microsoft/knowledge/privacy/classify-data-at-migration-destination.md new file mode 100644 index 0000000..f582fcc --- /dev/null +++ b/microsoft/knowledge/privacy/classify-data-at-migration-destination.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [migration, dataclassification, hybrid, destination, pii] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Classify migrated data at the destination field + +## Description + +Hybrid migration codeunits such as HybridSL, HybridGP, and HybridBC legitimately process sensitive source data: tax IDs, employee identifiers, financial balances, and customer records. The privacy concern is not that the migration code touches the data. The concern is where the data lands: the destination table field must have a DataClassification value that matches the migrated content. + +## Best Practice + +When reviewing migration code, follow the assignment to the destination field and verify that the destination table declares an appropriate field-level or inherited DataClassification. Treat the migration procedure itself as expected business functionality; flag only missing or understated classification on the persistent destination. + +See sample: `classify-data-at-migration-destination.good.al`. + +## Anti Pattern + +Flagging a migration procedure merely because it copies tax IDs or names from a source system. That creates false positives and misses the real issue: a destination field with no classification, `ToBeClassified`, or `SystemMetadata` for customer or employee data. + +See sample: `classify-data-at-migration-destination.bad.al`. diff --git a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md index cb3ec85..694c640 100644 --- a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md +++ b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md @@ -15,12 +15,12 @@ The privacy concern with user-facing text is not what the authenticated user see ## Best Practice -Free-text business content — customer names, email addresses, document numbers — is acceptable in Message, Confirm, and Notification. Treat Error text as if it will be read by telemetry consumers, because it will be. Use localized Labels with the fewest possible PII placeholders, or system identifiers (SystemId, primary key values) rather than personal data. +Free-text business content — customer names, email addresses, document numbers — is acceptable in Message, Confirm, and Notification. Treat Error text as if it will be read by telemetry consumers, because it will be, but use direct Error substitution rather than pre-building the message. `Error(MyErr, EmailAddress)` is telemetry-safe; `Error(StrSubstNo(..., EmailAddress))` is not. See sample: `error-is-logged-to-telemetry-message-is-not.good.al`. ## Anti Pattern -Embedding customer emails, phone numbers, addresses, or names directly into Error strings — either as literals or via pre-built StrSubstNo output — because "the user will see this anyway." The user also sees Message and Confirm, but those are not logged. Error is. +Embedding customer emails, phone numbers, addresses, or names as literals in an Error label or baking them into a Text value with StrSubstNo before calling Error. The user also sees Message and Confirm, but those are not logged. Error is logged, so dynamic customer data must stay as direct substitution arguments. See sample: `error-is-logged-to-telemetry-message-is-not.bad.al`. diff --git a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.bad.al b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.bad.al new file mode 100644 index 0000000..7411a0e --- /dev/null +++ b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.bad.al @@ -0,0 +1,14 @@ +codeunit 50936 "Privacy FeatureTelemetry Bad" +{ + procedure LogExpenseReleased(EmployeeNo: Code[20]; UserName: Text) + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + CustomDimensions: Dictionary of [Text, Text]; + begin + CustomDimensions.Add('EmployeeNo', EmployeeNo); + CustomDimensions.Add('UserName', UserName); + CustomDimensions.Add('LastError', GetLastErrorText()); + + FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions); + end; +} diff --git a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.good.al b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.good.al new file mode 100644 index 0000000..9300406 --- /dev/null +++ b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.good.al @@ -0,0 +1,13 @@ +codeunit 50935 "Privacy FeatureTelemetry Good" +{ + procedure LogExpenseReleased() + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + CustomDimensions: Dictionary of [Text, Text]; + begin + CustomDimensions.Add('DocumentType', 'Expense'); + CustomDimensions.Add('LineCountBucket', '10-20'); + + FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions); + end; +} diff --git a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.md b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.md new file mode 100644 index 0000000..d9c1bc7 --- /dev/null +++ b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [featuretelemetry, customdimensions, telemetry, pii, customercontent] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep customer data out of FeatureTelemetry custom dimensions + +## Description + +`Codeunit "Feature Telemetry"` writes telemetry through methods such as `LogUsage`, `LogUptake`, and `LogError`. The `CustomDimensions` dictionary passed to those methods is exported to the telemetry pipeline, so it has the same privacy boundary as `Session.LogMessage` dimensions. Customer names, email addresses, employee numbers, user IDs, security IDs, notes, and `GetLastErrorText()` do not become safe merely because they are structured dimensions. + +## Best Practice + +Log feature state, event names, counts, enum values, and non-personal technical identifiers. Omit customer and employee identifiers from `CustomDimensions`; if diagnostics need correlation, use a non-personal event ID or aggregate count instead. + +See sample: `keep-customer-data-out-of-featuretelemetry-dimensions.good.al`. + +## Anti Pattern + +Adding employee numbers, user names, customer emails, free-text descriptions, or raw `GetLastErrorText()` to the `CustomDimensions` dictionary before calling `FeatureTelemetry.LogUsage`, `LogUptake`, or `LogError`. + +See sample: `keep-customer-data-out-of-featuretelemetry-dimensions.bad.al`. diff --git a/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md b/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md new file mode 100644 index 0000000..4e8c99c --- /dev/null +++ b/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [tobeclassified, dataclassification, release, gdpr, placeholder] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Resolve ToBeClassified before release + +## Description + +`DataClassification = ToBeClassified` is a development marker, not a releasable privacy state. It tells reviewers and tooling that the field still needs classification work. Shipping it prevents data-subject, retention, and telemetry tooling from making a correct decision about the field. + +## Best Practice + +Replace every `ToBeClassified` value with the narrowest accurate classification before the PR ships to customers. If the field inherits a correct table-level DataClassification, remove the placeholder rather than leaving a field-level `ToBeClassified` override. + +## Anti Pattern + +Treating ToBeClassified as a safe default because the field is new or because the final classification is uncertain. Uncertainty should bias toward a stronger classification, not toward an unresolved placeholder. diff --git a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md index 042be42..cf8d257 100644 --- a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md +++ b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md @@ -11,7 +11,7 @@ application-area: [all] ## Description -`Session.LogMessage` accepts a DataClassification parameter that governs how the platform handles the logged content in the telemetry pipeline. Omitting it is a schema violation the platform cannot repair later. Embedding personal data — emails, names, phone numbers, addresses, filenames of user uploads — in the message string also defeats classification, because the pipeline sees opaque text and cannot selectively redact. +`Session.LogMessage` accepts a DataClassification parameter that governs how the platform handles the logged content in the telemetry pipeline. Omitting it is a schema violation the platform cannot repair later. Embedding personal data — emails, names, phone numbers, addresses, filenames of user uploads — in the message string also defeats classification, because the pipeline sees opaque text and cannot selectively redact. The same privacy boundary applies to other telemetry surfaces such as `Codeunit "Feature Telemetry"` custom dimensions. ## Best Practice diff --git a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md index 8210ec8..f70c00e 100644 --- a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md +++ b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md @@ -11,11 +11,11 @@ application-area: [all] ## Description -Error messages are captured by platform telemetry. When Error receives a format template and field references as substitution arguments (Error('... %1 ...', Customer."No.")), the platform inspects each field's DataClassification and omits sensitive values from telemetry automatically. When the caller pre-builds the message with StrSubstNo and then passes the resulting Text to Error, the platform sees a plain string with no field context and logs the whole thing verbatim — any PII already baked in is exported to telemetry. +Error messages are captured by platform telemetry. When Error receives a format template and substitution arguments directly (`Error('... %1 ...', Value)`), the platform can classify and strip sensitive values before telemetry is written. This is true whether the arguments are record fields, local variables, function results, or other expressions. When the caller pre-builds the message with StrSubstNo and then passes the resulting Text to Error, the platform sees a plain string with no argument context and logs the whole thing verbatim — any PII already baked in is exported to telemetry. ## Best Practice -Pass the template and the field references directly to Error. Declare the template as a Label with a Comment describing each placeholder. The platform's field-aware classification logic then takes care of what reaches telemetry. +Pass the template and substitution arguments directly to Error. Declare the template as a Label with a Comment describing each placeholder. Do not flag direct Error substitution merely because an argument may contain a customer name, email address, or phone number; the platform intercepts those arguments before telemetry. See sample: `strsubstno-prebuild-breaks-error-telemetry-classification.good.al`. diff --git a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md index e743bf1..e5e3e08 100644 --- a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md +++ b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md @@ -17,13 +17,13 @@ Events in AL are extensibility contracts. Every subscriber — third-party, inte ## Best Practice -Design event signatures to carry only the data a subscriber legitimately needs. Do not pass SecretText, credential material, or flags the publisher depends on for access control. If a subscriber needs to veto an action, model it as a separate OnBefore event whose Handled pattern is documented — not as a general-purpose var Boolean callers can flip. +Design event signatures to carry only the data a subscriber legitimately needs. Do not pass SecretText, credential material, or flags the publisher depends on for access control. Guard variables such as `HasAccess`, `SkipValidation`, or `CanExport` must not be `var` parameters on an OnBefore event; notify subscribers after the internal check with value parameters they cannot mutate. See sample: `do-not-expose-sensitive-data-in-event-publishers.good.al`. ## Anti Pattern -An OnBeforeElevateAccess publisher that exposes `var CanAccess: Boolean` — any subscriber installed on the tenant can flip it to true and escalate. Or a publisher that passes a SecretText parameter it obtained internally, handing it to every subscriber. +An OnBeforeElevateAccess publisher that exposes `var CanAccess: Boolean` or `var SkipValidation: Boolean` — any subscriber installed on the tenant can flip it to true and bypass the check. Or a publisher that passes a SecretText parameter it obtained internally, handing it to every subscriber. See sample: `do-not-expose-sensitive-data-in-event-publishers.bad.al`. diff --git a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.bad.al b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.bad.al new file mode 100644 index 0000000..3081231 --- /dev/null +++ b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.bad.al @@ -0,0 +1,12 @@ +codeunit 50243 "Sec Sample RecordRef Bad" +{ + procedure ArchiveRecord(RecId: RecordId) + var + RecRef: RecordRef; + begin + RecRef.Open(RecId.TableNo); + RecRef.Get(RecId); + RecRef.Delete(); + RecRef.Close(); + end; +} diff --git a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.good.al b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.good.al new file mode 100644 index 0000000..ec005d1 --- /dev/null +++ b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.good.al @@ -0,0 +1,12 @@ +codeunit 50242 "Sec Sample RecordRef Good" +{ + internal procedure ArchiveRecord(RecId: RecordId) + var + RecRef: RecordRef; + begin + RecRef.Open(RecId.TableNo); + RecRef.Get(RecId); + RecRef.Delete(); + RecRef.Close(); + end; +} diff --git a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.md b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.md new file mode 100644 index 0000000..609374f --- /dev/null +++ b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: security +keywords: [recordref, recordid, table-no, scope, inherentpermissions] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep caller-driven RecordRef.Open procedures non-public + +## Description + +A codeunit can hold permissions or `InherentPermissions` that its callers do not have. If it exposes a public procedure that accepts a table number or RecordId and calls `RecordRef.Open`, another extension can call that procedure to make the privileged codeunit open tables on its behalf. That turns a generic helper into a permission-bypass surface, especially for system tables. + +## Best Practice + +Procedures that call `RecordRef.Open` with a caller-provided table number must be `local`, `internal`, or `[Scope('OnPrem')]`. If the procedure truly must be public in SaaS, validate the table number against a narrow allowlist before opening the RecordRef. + +See sample: `keep-recordref-open-callers-non-public.good.al`. + +## Anti Pattern + +A public helper such as `ArchiveRecord(RecId: RecordId)` that opens `RecId.TableNo` and then reads, modifies, or deletes through RecordRef. The helper compiles, but it lets untrusted callers choose which table the privileged code opens. + +See sample: `keep-recordref-open-callers-non-public.bad.al`. diff --git a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al index 7cf0cfa..e0572f6 100644 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al +++ b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al @@ -10,6 +10,7 @@ codeunit 50209 "Sec Sample IsolatedStorage Bad" var ApiKey: Text; begin + // Public wrapper: another extension can call this to read the secret. if IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey) then exit(ApiKey); exit(''); diff --git a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al index a22b568..5dafba8 100644 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al +++ b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al @@ -1,11 +1,11 @@ codeunit 50208 "Sec Sample IsolatedStorage Good" { - procedure StoreApiKey(NewKey: SecretText) + internal procedure StoreApiKey(NewKey: SecretText) begin IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module); end; - procedure TryGetApiKey(var ApiKey: SecretText): Boolean + local procedure TryGetApiKey(var ApiKey: SecretText): Boolean begin if IsolatedStorage.Contains('ApiKey', DataScope::Module) then exit(IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey)); diff --git a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md index 3ef53a1..7e9d15b 100644 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md +++ b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md @@ -17,13 +17,13 @@ IsolatedStorage is a per-extension, per-tenant key-value store. DataScope::Modul ## Best Practice -Use IsolatedStorage.SetEncrypted to write secrets, IsolatedStorage.Contains to probe, and IsolatedStorage.Get into a SecretText destination to read. Choose DataScope::Company for per-company credentials (for example, a tenant-per-company service account) and DataScope::Module for extension-wide configuration. +Use IsolatedStorage.SetEncrypted to write secrets, IsolatedStorage.Contains to probe, and IsolatedStorage.Get into a SecretText destination to read. Choose DataScope::Company for per-company credentials (for example, a tenant-per-company service account) and DataScope::Module for extension-wide configuration. Procedures that call IsolatedStorage.Get, Set, SetEncrypted, Contains, or Delete must be `local` or `internal`; a public wrapper lets other extensions call into your storage boundary. See sample: `use-isolated-storage-for-module-and-company-secrets.good.al`. ## Anti Pattern -Storing secrets in a Setup table column as plain Text, or using IsolatedStorage.Set (unencrypted) for values that authenticate the extension to an external service. Both shapes leave the secret readable by anyone with read rights on the underlying storage. +Storing secrets in a Setup table column as plain Text, using IsolatedStorage.Set (unencrypted) for values that authenticate the extension to an external service, or exposing a public Get/Set procedure around IsolatedStorage. The first two leave secrets readable; the public wrapper lets another extension exfiltrate or overwrite values through your codeunit. See sample: `use-isolated-storage-for-module-and-company-secrets.bad.al`. diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al index 2fc4321..4058959 100644 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al +++ b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al @@ -13,4 +13,9 @@ codeunit 50217 "Sec Sample NonDebuggable Bad" JObject.Get('access_token', JToken); SessionToken := JToken.AsValue().AsText(); end; + + procedure BuildAuthorizationHeader(ApiKey: SecretText): Text + begin + exit('Bearer ' + ApiKey.Unwrap()); + end; } diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al index d055890..23b00a9 100644 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al +++ b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al @@ -12,4 +12,10 @@ codeunit 50216 "Sec Sample NonDebuggable Good" JObject.Get('access_token', JToken); SessionToken := JToken.AsValue().AsText(); end; + + [NonDebuggable] + procedure BuildAuthorizationHeader(ApiKey: SecretText): Text + begin + exit('Bearer ' + ApiKey.Unwrap()); + end; } diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md index 819a310..19c776c 100644 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md +++ b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md @@ -13,17 +13,17 @@ application-area: [all] ## Description -SecretText transit (assignment between SecretText variables, parameters, and return values) is protected automatically. Extracting a secret from a Text source — for example, reading an access token out of a parsed JSON response — is a legitimate Text-to-SecretText conversion during which the plaintext exists. The [NonDebuggable] attribute prevents debuggers (regular and snapshot) from inspecting the procedure's locals, parameters, and return at that moment. +SecretText transit (assignment between SecretText variables, parameters, and return values) is protected automatically. Extracting a secret from a Text source — for example, reading an access token out of a parsed JSON response — is a legitimate Text-to-SecretText conversion during which the plaintext exists. Calling `SecretText.Unwrap()` has the same exposure in the opposite direction: it materializes the secret as plain Text. The [NonDebuggable] attribute prevents debuggers (regular and snapshot) from inspecting the procedure's locals, parameters, and return at that moment. ## Best Practice -Apply [NonDebuggable] to any procedure that reads a response body, parses it, and assigns the extracted secret to a SecretText out-parameter or return. Keep the procedure narrow: it SHOULD do the minimum work required to obtain the SecretText, and nothing else. +Apply [NonDebuggable] to any procedure that reads a response body, parses it, and assigns the extracted secret to a SecretText out-parameter or return. Also apply it to every procedure that calls `Unwrap()` because the secret becomes plain Text inside that procedure. Keep the procedure narrow: it SHOULD do the minimum work required to obtain or unwrap the secret, and nothing else. See sample: `use-nondebuggable-when-parsing-secrets.good.al`. ## Anti Pattern -Parsing a token response in a normal (debuggable) procedure. The plaintext token is visible in debug sessions and snapshots taken during the parse. +Parsing a token response in a normal (debuggable) procedure, or calling `ApiKey.Unwrap()` there to build a legacy Text value. The plaintext token is visible in debug sessions and snapshots taken during the parse or unwrap. See sample: `use-nondebuggable-when-parsing-secrets.bad.al`. diff --git a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.bad.al b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.bad.al new file mode 100644 index 0000000..391f298 --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.bad.al @@ -0,0 +1,10 @@ +codeunit 50241 "Sec Sample Url Bad" +{ + procedure Sync(ServiceUrl: Text) + var + Client: HttpClient; + Response: HttpResponseMessage; + begin + Client.Get(ServiceUrl, Response); + end; +} diff --git a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.good.al b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.good.al new file mode 100644 index 0000000..5fb72fb --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.good.al @@ -0,0 +1,17 @@ +codeunit 50240 "Sec Sample Url Good" +{ + procedure Sync(ServiceUrl: Text) + var + Client: HttpClient; + Response: HttpResponseMessage; + Uri: Codeunit Uri; + ExpectedBaseUrl: Text; + begin + ExpectedBaseUrl := 'https://api.contoso.com'; + + if not Uri.AreURIsHaveSameHost(ServiceUrl, ExpectedBaseUrl) then + Error('Service URL must point to api.contoso.com.'); + + Client.Get(ServiceUrl, Response); + end; +} diff --git a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.md b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.md new file mode 100644 index 0000000..7a26165 --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: security +keywords: [url, uri, httpclient, ssrf, validation, endpoint] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Validate user-configurable URLs before HTTP calls + +## Description + +URLs stored in setup tables or accepted from user input are user-configurable endpoints. Passing them directly to `HttpClient` lets a malicious or compromised setup value redirect the extension to internal services, metadata endpoints, or attacker-controlled hosts. Business Central's System Application `Uri` codeunit provides host and pattern validation helpers for this exact boundary. + +## Best Practice + +Before `HttpClient.Get`, `Post`, `Put`, or similar calls use a URL from a table field, validate it with `Uri.AreURIsHaveSameHost()` when the host must be fixed, or `Uri.IsValidURIPattern()` when a known URL pattern is allowed. Validate before writing the request body so sensitive payloads are never sent to an unexpected host. + +See sample: `validate-user-configurable-urls-before-http-calls.good.al`. + +## Anti Pattern + +Reading `Setup."Service URL"` or `WebhookSetup."Callback URL"` and passing it directly to HttpClient. The code looks configurable, but it creates an SSRF path and can exfiltrate data to whichever host the setup row names. + +See sample: `validate-user-configurable-urls-before-http-calls.bad.al`. diff --git a/microsoft/knowledge/ui/keep-captions-on-editable-fields.bad.al b/microsoft/knowledge/ui/keep-captions-on-editable-fields.bad.al new file mode 100644 index 0000000..0d206a1 --- /dev/null +++ b/microsoft/knowledge/ui/keep-captions-on-editable-fields.bad.al @@ -0,0 +1,14 @@ +page 50731 "UI Caption Bad" +{ + layout + { + area(Content) + { + field(CustomerName; Rec."Customer Name") + { + InstructionalText = 'Enter the customer name.'; + ShowCaption = false; + } + } + } +} diff --git a/microsoft/knowledge/ui/keep-captions-on-editable-fields.good.al b/microsoft/knowledge/ui/keep-captions-on-editable-fields.good.al new file mode 100644 index 0000000..adb3ccd --- /dev/null +++ b/microsoft/knowledge/ui/keep-captions-on-editable-fields.good.al @@ -0,0 +1,21 @@ +page 50730 "UI Caption Good" +{ + layout + { + area(Content) + { + group(Description) + { + Caption = 'Description'; + field(DescriptionField; Rec.Description) + { + MultiLine = true; + ShowCaption = false; + } + } + field(CustomerName; Rec."Customer Name") + { + } + } + } +} diff --git a/microsoft/knowledge/ui/keep-captions-on-editable-fields.md b/microsoft/knowledge/ui/keep-captions-on-editable-fields.md new file mode 100644 index 0000000..aeea903 --- /dev/null +++ b/microsoft/knowledge/ui/keep-captions-on-editable-fields.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: ui +keywords: [showcaption, editable, accessibility, screen-reader, label] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep captions on editable fields + +## Description + +`ShowCaption = false` on an editable page field removes the visible and accessible label that identifies the input. `InstructionalText` is not a replacement: it behaves like placeholder text, disappears after entry, and is not reliably announced as the field label. The default `ShowCaption = true` is the safe form-field pattern. + +## Best Practice + +Leave captions visible on editable fields. `ShowCaption = false` is acceptable for non-editable content fields, for fields inside a valid data-table grid pattern, and for the first visible field in a parent group with a visible non-empty caption; in that last pattern, the group caption becomes the accessible label. + +See sample: `keep-captions-on-editable-fields.good.al`. + +## Anti Pattern + +Hiding the caption on an editable field because the page layout looks cleaner, or because `InstructionalText` appears to describe the input. Screen reader users lose the field label, and sighted users lose the persistent visual cue. + +See sample: `keep-captions-on-editable-fields.bad.al`. diff --git a/microsoft/knowledge/ui/manually-review-control-addin-ui-accessibility.md b/microsoft/knowledge/ui/manually-review-control-addin-ui-accessibility.md new file mode 100644 index 0000000..03123aa --- /dev/null +++ b/microsoft/knowledge/ui/manually-review-control-addin-ui-accessibility.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [control-addin, javascript, accessibility, wcag, keyboard, aria] +technologies: [al, javascript] +countries: [w1] +application-area: [all] +--- + +# Manually review UI-rendering control add-in changes for accessibility + +## Description + +JavaScript control add-ins bypass much of the Business Central client's built-in accessibility support. Once the add-in renders its own HTML, JavaScript, or CSS, the extension owns WCAG 2.1 AA concerns such as accessible names, semantic HTML, keyboard navigation, color contrast, focus management, and 200% zoom/reflow. Automated review cannot exhaustively verify those behaviours. + +## Best Practice + +When a control add-in change touches DOM creation, templates, CSS, interaction handlers, ARIA attributes, dynamic visibility, or focus flow, include a manual accessibility review finding even if no specific defect is obvious. Do not require manual accessibility review for pure data processing or API changes that do not render UI. + +## Anti Pattern + +Treating a control add-in diff as clean because no AL page properties changed. A new `div`-based button without an accessible name, a keyboard trap, or a color-only status indicator lives in JavaScript and still affects Business Central users. diff --git a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.bad.al b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.bad.al new file mode 100644 index 0000000..5af36ca --- /dev/null +++ b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.bad.al @@ -0,0 +1,19 @@ +page 50735 "UI Style Bad" +{ + layout + { + area(Content) + { + field(Score; Score) + { + Caption = 'Score'; + Style = Favorable; + StyleExpr = IsGood; + } + } + } + + var + Score: Integer; + IsGood: Boolean; +} diff --git a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.good.al b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.good.al new file mode 100644 index 0000000..496d892 --- /dev/null +++ b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.good.al @@ -0,0 +1,19 @@ +page 50734 "UI Style Good" +{ + layout + { + area(Content) + { + field(ValidationStatus; ValidationStatus) + { + Caption = 'Validation status'; + Style = Unfavorable; + StyleExpr = HasValidationErrors; + } + } + } + + var + ValidationStatus: Text; + HasValidationErrors: Boolean; +} diff --git a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.md b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.md new file mode 100644 index 0000000..f266288 --- /dev/null +++ b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: ui +keywords: [style, styleexpr, favorable, unfavorable, ambiguous, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Provide text meaning for semantic styles + +## Description + +Most Business Central page styles are cosmetic, but `Favorable`, `Unfavorable`, and `Ambiguous` communicate meaning through color. Color-only meaning is not accessible. A user who cannot perceive the style must still be able to determine whether the value is positive, negative, or uncertain from the caption, value, or nearby text. + +## Best Practice + +Use semantic styles only when the meaning is independently available: a caption such as "Error", a value such as "Failed", a signed number whose sign carries the meaning, or an adjacent status field. Cosmetic styles such as `Strong`, `Attention`, and `Subordinate` do not need this extra check. Cue tiles inside `cuegroup` are exempt because the client supplies accessible semantic labels. + +See sample: `provide-text-meaning-for-semantic-styles.good.al`. + +## Anti Pattern + +Applying `Style = Favorable`, `Unfavorable`, or `Ambiguous` to a value whose text is neutral, such as "42" or "Open", without any caption or adjacent field explaining what the color means. + +See sample: `provide-text-meaning-for-semantic-styles.bad.al`. diff --git a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.bad.al b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.bad.al new file mode 100644 index 0000000..2b34f6c --- /dev/null +++ b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.bad.al @@ -0,0 +1,24 @@ +page 50733 "UI Grid Bad" +{ + layout + { + area(Content) + { + grid(BalanceGrid) + { + GridLayout = Columns; + field(CustomerName; Rec."Customer Name") + { + ShowCaption = false; + } + group(BalanceColumn) + { + field(Balance; Rec.Balance) + { + ShowCaption = false; + } + } + } + } + } +} diff --git a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.good.al b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.good.al new file mode 100644 index 0000000..c290629 --- /dev/null +++ b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.good.al @@ -0,0 +1,29 @@ +page 50732 "UI Grid Good" +{ + layout + { + area(Content) + { + grid(BalanceGrid) + { + GridLayout = Columns; + group(CustomerColumn) + { + ShowCaption = false; + field(CustomerName; Rec."Customer Name") + { + ShowCaption = false; + } + } + group(BalanceColumn) + { + ShowCaption = false; + field(Balance; Rec.Balance) + { + ShowCaption = false; + } + } + } + } + } +} diff --git a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.md b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.md new file mode 100644 index 0000000..b30dcd1 --- /dev/null +++ b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, fixed, showcaption, accessibility, table-semantics] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use the grid data-table pattern consistently + +## Description + +Business Central `grid` and `fixed` layouts render either as data tables or layout tables based on a structural heuristic. A data table requires all direct children to be groups, every group child to be a field, and all fields to have `ShowCaption = false`. If the structure fails that heuristic, the client renders a layout table; hidden captions on editable fields then remove the only accessible labels. + +## Best Practice + +Use one pattern consistently. For a data-table grid, make every direct child a group and every field `ShowCaption = false`. For a layout grid, keep captions visible on editable or tabular fields and hide captions only on standalone non-editable content where the missing label is not a form-field problem. + +See sample: `use-grid-data-table-pattern-consistently.good.al`. + +## Anti Pattern + +Mixing the patterns: one loose field, nested group, or visible field caption prevents data-table rendering, while other editable fields still hide captions. The result looks like a table visually but has layout-table semantics and missing labels for assistive technology. + +See sample: `use-grid-data-table-pattern-consistently.bad.al`. diff --git a/microsoft/knowledge/upgrade/assess-existing-data-before-key-or-type-changes.md b/microsoft/knowledge/upgrade/assess-existing-data-before-key-or-type-changes.md new file mode 100644 index 0000000..63ffed1 --- /dev/null +++ b/microsoft/knowledge/upgrade/assess-existing-data-before-key-or-type-changes.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [primary-key, field-type, existing-data, schema, breaking-change] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Assess existing data before primary-key or field-type changes + +## Description + +Primary-key and field-type changes are upgrade concerns because existing rows may no longer map safely to the new schema. The risk depends on whether the table already has tenant data and whether the old values can be converted without loss. New feature tables with no production rows do not have the same migration burden as ledger, document, or base application tables. + +## Best Practice + +For existing tables with data, require a concrete migration or compatibility assessment before changing keys or field types. For new tables, new feature tables, or Integer-to-BigInteger changes with evidence that existing values fit, avoid flagging a breaking-change finding without data-impact evidence. + +## Anti Pattern + +Treating every primary-key edit in a new feature table as a blocker while missing a key or type change on an established ledger-like table. Reviewers need to tie the finding to existing tenant data, not just to the syntactic shape of the schema edit. diff --git a/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md b/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md index 57bdde3..e7bf1e6 100644 --- a/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md +++ b/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md @@ -15,7 +15,7 @@ The upgrade scope has to complete for the tenant to reach the new version. Any c ## Best Practice -Defer external calls to runtime code that executes after the upgrade — install-triggered tasks, background job queue entries scheduled by the upgrade, or lazy initialization on first use. The upgrade step should compute a local result or mark work to be done, not perform the remote call itself. +Defer external calls to runtime code that executes after the upgrade — install-triggered tasks, background job queue entries scheduled by the upgrade, or lazy initialization on first use. The upgrade step should compute a local result or mark work to be done, not perform the remote call itself. Do not apply this rule to ordinary runtime codeunits, pages, tables, install procedures, or background jobs unless they are directly invoked from an upgrade trigger. ## Anti Pattern diff --git a/microsoft/knowledge/upgrade/exclude-hybrid-migration-codeunits-from-standard-upgrade-rules.md b/microsoft/knowledge/upgrade/exclude-hybrid-migration-codeunits-from-standard-upgrade-rules.md new file mode 100644 index 0000000..66bad0a --- /dev/null +++ b/microsoft/knowledge/upgrade/exclude-hybrid-migration-codeunits-from-standard-upgrade-rules.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [hybrid, migration, upgrade-tag, false-positive, datamigration] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Exclude Hybrid migration codeunits from standard upgrade rules + +## Description + +Hybrid migration codeunits such as `HybridBC14`, `HybridSL`, `HybridGP`, and `HybridBaseDeployment` are one-time migration paths with established migration-specific patterns. They are not ordinary `Subtype = Upgrade` steps, and forcing standard upgrade-tag, trigger-shape, or missing-upgrade-code rules onto them creates false positives. + +## Best Practice + +When a change is clearly in a Hybrid migration codeunit or migration namespace, review it against migration-specific data handling and destination classification rules. Do not flag it merely because it lacks ordinary upgrade tags or because its control flow differs from standard upgrade codeunits. + +## Anti Pattern + +Reporting "missing upgrade tag" or "missing standard upgrade code" on a `HybridSL`, `HybridGP`, `HybridBC`, or `HybridBaseDeployment` codeunit solely because it does not look like a normal upgrade step. The name and migration context are the signal that different rules apply. diff --git a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.bad.al b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.bad.al new file mode 100644 index 0000000..08f88b1 --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.bad.al @@ -0,0 +1,13 @@ +codeunit 50831 "Upgrade Sample Trigger Bad" +{ + Subtype = Upgrade; + + trigger OnValidateUpgradePerCompany() + begin + ValidateAllCustomers(); + end; + + local procedure ValidateAllCustomers() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.good.al b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.good.al new file mode 100644 index 0000000..7e29e43 --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.good.al @@ -0,0 +1,25 @@ +codeunit 50830 "Upgrade Sample Trigger Good" +{ + Subtype = Upgrade; + + trigger OnValidateUpgradePerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + // Required for regulatory data validation before this release can run. + if UpgradeTag.HasUpgradeTag(ValidationTag()) then + exit; + + ValidateAllCustomers(); + UpgradeTag.SetUpgradeTag(ValidationTag()); + end; + + local procedure ValidateAllCustomers() + begin + end; + + local procedure ValidationTag(): Code[250] + begin + exit('MS-000010-ValidateCustomers-20260501'); + end; +} diff --git a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.md b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.md new file mode 100644 index 0000000..0bfd375 --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [onvalidateupgrade, trigger, upgrade-tag, performance, justification] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Guard performance-impacting upgrade triggers + +## Description + +Upgrade validation triggers such as `OnValidateUpgradePerCompany` can run during upgrade for every tenant and company. Expensive validation, full-table scans, or repair logic in those triggers becomes part of the upgrade's critical path. The trigger is acceptable only when the work is necessary and when re-execution is prevented. + +## Best Practice + +Add written justification for the trigger's work and guard it with an upgrade tag just like a data-migration step. Check `HasUpgradeTag` before the expensive work and call `SetUpgradeTag` only after the work succeeds, so retries do not re-run completed validation. + +See sample: `guard-performance-impacting-upgrade-triggers.good.al`. + +## Anti Pattern + +Putting `ValidateAllCustomers()`, table scans, or external-style setup validation directly in `OnValidateUpgradePerCompany` without a skip tag. The work runs on every upgrade attempt, including retries after unrelated failures. + +See sample: `guard-performance-impacting-upgrade-triggers.bad.al`. diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md index 6f0b612..66669ac 100644 --- a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md +++ b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md @@ -15,7 +15,7 @@ The `InitValue` property sets a field's default for rows created after the field ## Best Practice -When adding a field to an existing table with a meaningful default, write an upgrade step that populates existing rows with the same value, guarded by its own upgrade tag. Use `DataTransfer` with `AddConstantValue` for set-based initialization (see `use-datatransfer-for-large-dataset-initialization`). Exceptions: brand-new tables, new Boolean fields where `false` is the correct value for existing rows, and informational fields where empty is an acceptable state. +When adding a field to an existing table with a meaningful default, write an upgrade step that populates existing rows with the same value, guarded by its own upgrade tag. Use `DataTransfer` with `AddConstantValue` for set-based initialization (see `use-datatransfer-for-large-dataset-initialization`). Exceptions: brand-new tables; new Boolean fields without InitValue where `false` is the intended existing-row value; new extensions, new feature tables, or setup tables with no meaningful existing data to migrate; and informational fields where empty is an acceptable state. See sample: `initvalue-does-not-populate-existing-records.good.al`. diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md index fd260dd..9f34007 100644 --- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md @@ -15,12 +15,12 @@ An upgrade tag set via `UpgradeTag.SetUpgradeTag` only participates in the platf ## Best Practice -For every upgrade-tag constant referenced in `HasUpgradeTag`/`SetUpgradeTag`, register it in the subscriber that matches its trigger scope: tags used from `OnUpgradePerCompany` go in `OnGetPerCompanyUpgradeTags`; tags used from `OnUpgradePerDatabase` go in `OnGetPerDatabaseUpgradeTags`. Keep the tag string in a single source (Label or function) and reference it at the guard, the setter, and the registration. +For every upgrade-tag constant referenced in `HasUpgradeTag`/`SetUpgradeTag`, register it in the subscriber that matches its trigger scope: tags used from `OnUpgradePerCompany` go in `OnGetPerCompanyUpgradeTags`; tags used from `OnUpgradePerDatabase` go in `OnGetPerDatabaseUpgradeTags`. Treat this mapping as a review point, not just a naming convention. Keep the tag string in a single source (Label or function) and reference it at the guard, the setter, and the registration. See sample: `register-upgrade-tags-with-subscribers.good.al`. ## Anti Pattern -Adding a new `UpgradeTag.SetUpgradeTag(MyTag())` without the matching `PerCompanyUpgradeTags.Add(MyTag())` in the registration subscriber. The code compiles and the step completes, but the tag is unregistered and the infrastructure is partially disabled. +Adding a new `UpgradeTag.SetUpgradeTag(MyTag())` without the matching `PerCompanyUpgradeTags.Add(MyTag())` in the registration subscriber, or registering a tag used from `OnUpgradePerCompany` in `OnGetPerDatabaseUpgradeTags`. The code compiles and the step completes, but the tag is invisible or registered at the wrong scope. See sample: `register-upgrade-tags-with-subscribers.bad.al`. diff --git a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md index ba14648..96d7526 100644 --- a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md +++ b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md @@ -11,13 +11,13 @@ application-area: [all] ## Description -An upgrade that populates a new field on millions of existing rows with a FindSet+Modify loop pays a round-trip and a per-row trigger invocation for every row — turning a multi-hour upgrade into a multi-day one on ledger-entry-scale tables. `DataTransfer` pushes the update to SQL as a single set-based operation using source filters and constant values, which is the supported platform mechanism for this scenario. The tradeoff: DataTransfer bypasses validation triggers and event subscribers — if the step depends on trigger logic, that has to be reconstructed explicitly. +An upgrade that populates a new field on existing rows with a FindSet+Modify loop pays a round-trip and a per-row trigger invocation for every row — turning a multi-hour upgrade into a multi-day one on ledger-entry-scale tables. `DataTransfer` pushes the update to SQL as a single set-based operation using source filters and constant values, which is the supported platform mechanism for this scenario. The tradeoff: DataTransfer bypasses validation triggers and event subscribers — if the step depends on trigger logic, that has to be reconstructed explicitly. ## Best Practice -Use DataTransfer when initializing a new field on an existing table that **can contain more than 300,000 records**, or whenever a new field is added to an existing table and the initialization must run across all existing rows. Tables in the ledger-entry and document-line category reliably exceed this threshold; treat them as requiring DataTransfer by default. +Use DataTransfer when a new field added to an existing table needs initialization across existing rows, and for any table that can contain more than 300,000 records. Tables in the ledger-entry and document-line category reliably exceed this threshold; treat them as requiring DataTransfer by default. -Set tables, add source filters, add constant values, call CopyFields, clear, and repeat for additional slices. When trigger or subscriber behaviour is required, do that work separately against a filtered result set so the bulk update remains set-based. +Set tables, add source filters, add constant values, call CopyFields, clear, and repeat for additional slices. Use the pattern for new fields and tables added in the same change. If no new field or table is involved, document why validation triggers and event subscribers are safe to bypass, or keep the explicit loop that invokes the business logic. See sample: `use-datatransfer-for-large-dataset-initialization.good.al`. diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md index 68f81c0..86f82bb 100644 --- a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md @@ -15,12 +15,12 @@ application-area: [all] ## Best Practice -Guard each step with `if UpgradeTag.HasUpgradeTag(MyTag()) then exit;` at the top of the procedure. After the step completes, call `UpgradeTag.SetUpgradeTag(MyTag())`. Define the tag string in a `Tok`-suffixed Label or returning function so the same constant is referenced at both the guard and the registration (see `register-upgrade-tags-with-getpercompany-getperdatabase-subscribers`). +Guard each standard upgrade step with `if UpgradeTag.HasUpgradeTag(MyTag()) then exit;` at the top of the procedure. After the step completes, call `UpgradeTag.SetUpgradeTag(MyTag())`. Define the tag string in a `Tok`-suffixed Label or returning function so the same constant is referenced at both the guard and the registration (see `register-upgrade-tags-with-getpercompany-getperdatabase-subscribers`). The supported DataVersion exception is first-install detection in `OnInstallAppPerCompany` with the `0.0.0.0` sentinel; one-time Hybrid migration codeunits follow separate migration patterns and should not be forced into ordinary upgrade-tag structure. See sample: `use-upgrade-tags-not-version-checks.good.al`. ## Anti Pattern -`if MyApp.DataVersion().Major < 18 then UpgradeFeatureA();` — the step runs on every upgrade from a pre-18 version, may fail on partial data, and the next retry re-runs work that already succeeded. Nesting version-check branches (`< 14` → step A, `< 17` → step B) compounds the fragility. +`if MyApp.DataVersion().Major < 18 then UpgradeFeatureA();` inside a standard upgrade step — the step runs on every upgrade from a pre-18 version, may fail on partial data, and the next retry re-runs work that already succeeded. Nesting version-check branches (`< 14` → step A, `< 17` → step B) compounds the fragility. See sample: `use-upgrade-tags-not-version-checks.bad.al`. diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index 4c143e4..92933bf 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially tables, pages with SourceTable bindings, reports, queries, and codeunits performing record iteration. - The changed procedures and triggers, weighted toward those that perform loops, Find/FindSet/FindFirst calls, CalcFields, CalcSums, FlowField access, or cross-table navigation. -- Tokens extracted from the diff that relate to data access (SetRange, SetFilter, SetLoadFields, SetCurrentKey, FindSet, Repeat…Until, CalcFields, CalcSums). +- Tokens extracted from the diff that relate to data access and hot-path costs (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `CalcSums`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from filename and Description) matches a changed object type. diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index 15a385c..f78f9af 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -35,11 +35,11 @@ Discard files that are not applicable. Retain conditionally applicable files (an ## Worklist -Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: +Narrow the relevant files to the subset that applies to the changes under review. Exclude test codeunits, test libraries, test helper code, files under test/Test/Tests paths, and objects with `Subtype = Test`; test data is synthetic and does not ship to customers. For each relevant file, compute overlap against: -- The changed AL object names and types — especially tables and tableextensions (for `DataClassification` on fields), codeunits that call `Error` or `Session.LogMessage`, codeunits performing outgoing HTTP requests with customer data, and objects reading or writing `IsolatedStorage`. -- The changed procedures and triggers, weighted toward those that call `Error`, `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`, `HttpClient.Post`/`Get`, `IsolatedStorage.Set`/`SetEncrypted`/`Get`, or `PrivacyNotice.GetPrivacyNoticeApprovalState`. -- Tokens extracted from the diff that relate to privacy (`DataClassification`, `CustomerContent`, `EndUserIdentifiableInformation`, `SystemMetadata`, `ToBeClassified`, `PrivacyNotice`, `GetLastErrorText`, `TelemetryScope`). +- The changed AL object names and types — especially tables and tableextensions (for `DataClassification` on fields), codeunits that call `Error`, `Session.LogMessage`, or `FeatureTelemetry`, codeunits performing outgoing HTTP requests with customer data, migration codeunits, and objects reading or writing `IsolatedStorage`. +- The changed procedures and triggers, weighted toward those that call `Error`, `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`, `FeatureTelemetry.LogUsage`/`LogUptake`/`LogError`, `HttpClient.Post`/`Get`, `IsolatedStorage.Set`/`SetEncrypted`/`Get`, or `PrivacyNotice.GetPrivacyNoticeApprovalState`. +- Tokens extracted from the diff that relate to privacy (`DataClassification`, `CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `SystemMetadata`, `ToBeClassified`, `PrivacyNotice`, `GetLastErrorText`, `TelemetryScope`, `FeatureTelemetry`, `CustomDimensions`, `LogUsage`, `LogUptake`, `LogError`, `HybridSL`, `HybridGP`, `HybridBC`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from filename and Description) matches a changed object type. diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index 126cd9c..c9b326e 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -37,9 +37,9 @@ Discard files that are not applicable. Retain conditionally applicable files (an Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: -- The changed AL object names and types — especially permission sets, codeunits handling authentication or authorization, objects touching `Isolated Storage`, `OAuth2` flows, web service endpoints, and API pages. -- The changed procedures and triggers, weighted toward those that call `HttpClient`, write to telemetry, read or write secrets, manipulate record-level security, or bypass the permission model (for example, `Record.WritePermission`, direct table access from a non-owning app). -- Tokens extracted from the diff that relate to security concerns (`IsolatedStorage`, `OAuth2`, `Secret`, `Password`, `Token`, `HttpClient`, `Permission`, `Session`, `UserSecurityId`, `Commit`). +- The changed AL object names and types — especially permission sets, codeunits handling authentication or authorization, objects touching `Isolated Storage`, `OAuth2` flows, web service endpoints, API pages, event publishers, and RecordRef helpers. +- The changed procedures and triggers, weighted toward those that call `HttpClient`, validate or compose URLs, write to telemetry, read or write secrets, unwrap SecretText, manipulate record-level security, expose var Boolean guard parameters, or bypass the permission model (for example, `RecordRef.Open`, `Record.WritePermission`, direct table access from a non-owning app). +- Tokens extracted from the diff that relate to security concerns (`IsolatedStorage`, `SetEncrypted`, `OAuth2`, `SecretText`, `Unwrap`, `NonDebuggable`, `Password`, `Token`, `HttpClient`, `Uri`, `AreURIsHaveSameHost`, `IsValidURIPattern`, `RecordRef`, `RecordId`, `Open`, `IntegrationEvent`, `SkipValidation`, `HasAccess`, `Permission`, `UserSecurityId`, `Commit`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from filename and Description) matches a changed object type. diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index 70e8c52..463ee45 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -2,21 +2,21 @@ kind: action-skill id: al-ui-review version: 1 -title: AL UI text review -description: Reviews AL page files against UI-text, caption, and tooltip guidance from BCQuality. +title: AL UI and accessibility review +description: Reviews AL page and control add-in UI files against UI text, caption, tooltip, and accessibility guidance from BCQuality. inputs: [pr-diff, file-path] outputs: [findings-report] bc-version: [all] -technologies: [al] +technologies: [al, javascript] countries: [w1] application-area: [all] --- -# AL UI text review +# AL UI and accessibility review -Reviews AL page source against the `ui` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. +Reviews AL page source and control add-in UI files against the `ui` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -UI findings apply to page files — files that declare `PageType = ...`, including `*.Page.al` under the standard file-naming convention. The skill returns `not-applicable` when the diff contains no page changes. +UI findings apply to page files — files that declare `PageType = ...`, including `*.Page.al` under the standard file-naming convention — and to JavaScript/CSS/HTML files that render Business Central control add-ins. The skill returns `not-applicable` when the diff contains no page or control add-in UI changes. An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces a single JSON document conforming to the DO output contract. @@ -29,7 +29,7 @@ Collect all knowledge files under `*/knowledge/ui/**/*.md`, across every enabled Apply the frontmatter matching rules defined in READ against the task context: - `bc-version` — the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`. -- `technologies` — `[al]`. +- `technologies` — `[al]` or `[javascript]`. - `countries` — the countries declared in the consuming app's `app.json`. If absent, `unknown`. - `application-area` — pass the actual set declared by the changed objects; do not substitute `[all]`. @@ -39,9 +39,9 @@ Discard files that are not applicable. Retain conditionally applicable files onl Narrow the relevant files to the subset that applies to the changes under review. -- **Page-file filter.** UI review applies only to files declaring `page`, `pageextension`, or `pagecustomization`. When the diff contains no such files, return `outcome: "not-applicable"` without evaluating knowledge files. -- For each relevant knowledge file, compute overlap against changed page declarations, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, action definitions, and field-level properties. -- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions). +- **UI-file filter.** UI review applies to files declaring `page`, `pageextension`, or `pagecustomization`, and to control add-in JavaScript/CSS/HTML that changes rendered UI. When the diff contains no such files, return `outcome: "not-applicable"` without evaluating knowledge files. +- For each relevant knowledge file, compute overlap against changed page declarations and control add-in UI files, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, `ShowCaption`, `InstructionalText`, `GridLayout`, `Style`, `StyleExpr`, action definitions, field-level properties, DOM creation, ARIA attributes, and keyboard/focus handlers. +- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic matches a changed page element. @@ -51,7 +51,7 @@ When the post-conflict worklist is empty because no applicable UI knowledge exis ## Action -For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. UI text findings are generally `minor` — they affect localization and polish rather than correctness. Reach for `major` only when a banned term appears in customer-facing text or a caption truncation is guaranteed at the stated character limit. +For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. UI text findings are generally `minor` — they affect localization and polish rather than correctness. Accessibility findings for missing labels, broken grid semantics, semantic color without text meaning, or UI-rendering control add-in changes can be `major`; use `minor` for low-risk manual-review reminders and polish issues. Set `confidence` to: @@ -63,7 +63,7 @@ Outcome selection: - `completed` — the skill evaluated every worklist item. - `no-knowledge` — no applicable UI knowledge survived filtering. -- `not-applicable` — the diff contains no page, pageextension, or pagecustomization files. +- `not-applicable` — the diff contains no page, pageextension, pagecustomization, or control add-in UI files. - `partial` — a budget was hit before the worklist was exhausted. - `failed` — an unrecoverable error occurred. diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md index b66ed36..313694c 100644 --- a/microsoft/skills/review/al-upgrade-review.md +++ b/microsoft/skills/review/al-upgrade-review.md @@ -38,8 +38,8 @@ Discard files that are not applicable. Retain conditionally applicable files (an Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: - The changed AL object names and types — especially codeunits with `Subtype = Upgrade` or `Subtype = Install`, tables and tableextensions adding or changing fields, enums and enumextensions, and objects under `Hybrid*`/`Migration`/`Upgrade` namespaces. -- The changed triggers and procedures, weighted toward `OnUpgradePerCompany`, `OnUpgradePerDatabase`, `OnInstallAppPerCompany`, and the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers. -- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `DataTransfer`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `DataVersion`, `ExecutionContext`, `value(`, `enum`, `enumextension`). +- The changed triggers and procedures, weighted toward `OnUpgradePerCompany`, `OnUpgradePerDatabase`, `OnValidateUpgradePerCompany`, `OnValidateUpgradePerDatabase`, `OnInstallAppPerCompany`, and the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers. +- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnValidateUpgrade`, `DataTransfer`, `CopyFields`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `DataVersion`, `ExecutionContext`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic matches a changed object type. When the diff contains no upgrade-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. From 637e7ac602bb38ed54a4a7cb9ddb8f3c74cf3de5 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 21 May 2026 09:14:30 +0200 Subject: [PATCH 14/15] Make BCQuality an additive knowledge layer with agent findings Let super-skills surface findings the agent identifies on its own, clearly tagged so consumers can render them differently from knowledge-backed ones. - skills/do.md: permit references:[] when from-sub-skill='agent'; define the agent-finding encoding (id 'agent:', confidence capped at medium, self-contained message); restrict agent findings to super-skills only. - microsoft/skills/review/al-code-review.md: add a self-review pass to Action that validates agent-identified candidates against BCQuality (cite if matched, suppress if contradicted, surface as agent finding otherwise). Add example finding. - agent-consumption.md, README.md: describe the additive model and the from-sub-skill: 'agent' marker so consumer orchestrators know to render unbacked findings. Strictly additive: existing knowledge-backed flow is unchanged and backward compatible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 ++ agent-consumption.md | 11 ++++++ microsoft/skills/review/al-code-review.md | 42 +++++++++++++++++++++-- skills/do.md | 16 +++++++-- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1e353fc..e7a180e 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,8 @@ Action skills follow a four-step pattern: Every action skill produces output in a common format that orchestrators can consume without skill-specific parsing. The format is JSON and includes an `outcome` (so a clean run, a not-applicable skill, and a partial failure are all distinguishable), `findings` (what the skill observed), structured `references` back to the knowledge files that informed each finding, per-finding `confidence`, and a `suppressed` list recording any knowledge files overridden by layer precedence. This contract is defined in the Action Skill meta-skill so that orchestrators and action skills remain independently evolvable. +BCQuality is an **additive** knowledge layer: it augments the agent's review judgement, it does not replace it. Super-skills (such as `al-code-review`) run a self-review pass alongside their sub-skills and surface concerns the agent identified on its own, marked with `from-sub-skill: "agent"` and an empty `references: []` so consumers can render them distinctly from knowledge-backed findings. See [agent-consumption.md](agent-consumption.md) and [`skills/do.md`](skills/do.md) for the full contract. + The meta-skills in `/skills/` define this pattern. Every concrete action skill follows it. For the end-to-end flow — from orchestrator trigger through to how output reaches developers — see [agent-consumption.md](agent-consumption.md). diff --git a/agent-consumption.md b/agent-consumption.md index f000acf..cbeb07f 100644 --- a/agent-consumption.md +++ b/agent-consumption.md @@ -66,6 +66,17 @@ The orchestrator parses this **without skill-specific logic**. This is the point ### 7. Orchestrator integrates The orchestrator turns findings into PR comments, build gates, or IDE diagnostics, and links the references back to the knowledge files so the PR author — human or agent — can read the guidance. +## Knowledge-backed and agent findings + +BCQuality is an **additive** knowledge layer. The agent surfaces two kinds of findings, both shaped to the same DO output contract: + +- **Knowledge-backed findings** carry one or more entries in `references[]` pointing at BCQuality knowledge files. Their `id` is the primary file's repo-relative path. These are produced by leaf sub-skills and rolled up by super-skills. +- **Agent findings** are surfaced by a super-skill from its own self-review pass when no BCQuality knowledge file backs the concern. They are tagged with `from-sub-skill: "agent"`, carry an empty `references: []`, use a slug `id` prefixed `agent:`, and have `confidence` capped at `medium`. Their `message` is self-contained because there is no knowledge-file footer to fall back on. + +Before a super-skill emits an agent finding, it validates the candidate against the BCQuality knowledge already loaded for the task: a matching file upgrades the candidate to a knowledge-backed finding (and merges or deduplicates against the relevant sub-skill output); a contradicting file suppresses the candidate. Only candidates with no BCQuality coverage become agent findings. + +Orchestrators MAY render the two kinds differently — for example, by labelling agent findings or routing them to a separate review domain — and MAY apply independent severity floors. The `from-sub-skill: "agent"` marker is the contract. + ## Why this architecture - **Entry is the only hardcoded thing.** Orchestrators ship with one convention — *"invoke `/skills/entry.md` first"* — and nothing else. New action skills and new knowledge files are picked up automatically because Entry discovers them at dispatch time. diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 382590d..041c0cd 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -23,7 +23,7 @@ sub-skills: Reviews AL source changes by composing the leaf AL review skills. This is the canonical reference implementation of a **super-skill** — skill authors writing composed reviews should copy its structure. -`al-code-review` does not evaluate knowledge files directly. It invokes each of its sub-skills against the same task input, collects their findings-reports, and returns a rolled-up findings-report. +`al-code-review` does not evaluate knowledge files directly. It invokes each of its sub-skills against the same task input, collects their findings-reports, and then performs its own **self-review pass** over the diff using the agent's built-in BC and AL knowledge. BCQuality knowledge is an additive layer: anything the sub-skills found is cited from BCQuality, and anything the agent finds on its own is validated against BCQuality (cited if matched, suppressed if contradicted, surfaced as an **agent finding** otherwise). The result is a single rolled-up findings-report that mixes knowledge-backed and agent findings, each clearly tagged via `from-sub-skill`. An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract, extended with `sub-results` and — when applicable — `skipped-sub-skills`. @@ -60,6 +60,8 @@ The worklist is the list of sub-skills judged relevant by the previous step. Eve ## Action +### Roll up sub-skill findings + For each sub-skill in the worklist: 1. Invoke the sub-skill with the orchestrator's inputs, passing only the subset each sub-skill declares in its `inputs`. @@ -67,7 +69,28 @@ For each sub-skill in the worklist: 3. If the sub-skill's `outcome` is `failed`, stop here for this sub-skill: its findings are not reliable per the DO contract and MUST NOT be copied into the super-skill's top-level `findings[]` or counted in `summary.counts`. 4. Otherwise, append each entry from the sub-skill's `findings[]` to the super-skill's top-level `findings[]`, setting `from-sub-skill` to the sub-skill's `skill.id`. For non-citation findings (those whose `id` is a skill-defined slug rather than a reference path), prefix `id` with `:` to prevent collisions across sub-skills. Other finding fields are preserved. -Aggregate `summary.counts` and `summary.coverage` as the sums across invoked sub-skills whose `outcome` is not `failed`. +### Agent self-review pass + +After the sub-skill rollup, perform a self-review pass against the same task input using the agent's built-in BC and AL knowledge. BCQuality is an **additive** knowledge layer: it augments the agent's review judgement, it does not replace it. The goal of this pass is to surface defects the agent recognises on its own — bugs, anti-patterns, error-handling gaps, AL idioms — that the leaf sub-skills did not catch because no BCQuality knowledge file covers them yet. + +For every candidate the agent identifies in this pass: + +1. **Validate against BCQuality knowledge.** Check the candidate against the knowledge files the sub-skills have already loaded for this task (visible via their `references` and `suppressed` lists in `sub-results`). + - If a BCQuality knowledge file matches the candidate, upgrade it to a knowledge-backed finding: cite the file in `references`, set `id` to the file's path, set `from-sub-skill` to the sub-skill that owns that knowledge domain, and merge with or deduplicate against any sub-skill finding that already covers the same concern at the same location. + - If a BCQuality knowledge file **explicitly contradicts** the candidate (its `## Best Practice` or `## Anti Pattern` says the opposite of what the agent flagged), suppress the candidate and do not surface it. + - Otherwise the candidate has no BCQuality coverage; emit it as an agent finding. +2. **Emit agent finding.** Per DO's *Agent findings* rules: + - `from-sub-skill: "agent"` + - `references: []` + - `id` is a skill-defined slug prefixed with `agent:` (for example, `agent:missing-error-handling-on-http-call`). + - `confidence` capped at `medium`. + - `message` is non-empty and self-contained, describing both the issue and a concrete recommendation. A consumer rendering the finding has no knowledge-file footer to fall back on. + +Leaf sub-skills MUST NOT emit agent findings: their scope is bounded by the knowledge subset they evaluate. The self-review pass is a super-skill responsibility. + +### Summary and rollup + +Aggregate `summary.counts` and `summary.coverage` as the sums across invoked sub-skills whose `outcome` is not `failed`. Agent findings emitted by the super-skill itself contribute to `summary.counts` but not to `summary.coverage` (coverage is a sub-skill worklist metric and is undefined for self-review). `suppressed[]` at the super-skill level remains empty. Knowledge-file-level suppression is reported by each sub-skill within its own entry in `sub-results`. @@ -82,7 +105,7 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "skill": { "id": "al-code-review", "version": 1 }, "outcome": "completed", "summary": { - "counts": { "blocker": 1, "major": 1, "minor": 2, "info": 0 }, + "counts": { "blocker": 1, "major": 1, "minor": 3, "info": 0 }, "coverage": { "worklist-size": 4, "items-evaluated": 4 } }, "findings": [ @@ -143,6 +166,19 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip ], "confidence": "medium", "from-sub-skill": "al-security-review" + }, + { + "id": "agent:missing-error-handling-on-http-client", + "severity": "minor", + "message": "HttpClient.Send is called without inspecting the response status or wrapping the call in a TryFunction. Network or remote-server failures will surface as runtime errors to the user. Recommendation: branch on the HttpResponseMessage.IsSuccessStatusCode and either retry, surface a controlled error, or fall back, depending on the integration's contract.", + "location": { + "file": "src/Integration/ApiClient.Codeunit.al", + "line": 60, + "range": { "start-line": 60, "end-line": 64 } + }, + "references": [], + "confidence": "medium", + "from-sub-skill": "agent" } ], "suppressed": [], diff --git a/skills/do.md b/skills/do.md index 0fbd430..fd126bf 100644 --- a/skills/do.md +++ b/skills/do.md @@ -133,6 +133,18 @@ An empty `findings` array with `outcome: completed` means the skill ran and foun When a super-skill rolls up a non-citation finding from a sub-skill (an `id` that is a slug, not a path), the super-skill MUST prefix the `id` with `:` to avoid collisions across sub-skills (for example, a slug `missing-test` from `al-security-review` becomes `al-security-review:missing-test`). Citation-based findings are already globally unique through their repo-relative path and MUST NOT be rewritten. +**Agent findings.** A super-skill MAY emit findings that the agent identified through its own reasoning rather than from a BCQuality knowledge file. BCQuality is an **additive** knowledge layer: it augments the agent's pre-existing review judgement, it does not replace it. An agent finding is encoded by: + +- `from-sub-skill: "agent"` — the canonical marker. Use this exact value; do not invent equivalents. +- `references: []` — required. An agent finding has no knowledge-file citation by definition; if a citation existed, the finding would be a knowledge-backed finding instead. +- `id` — a skill-defined slug, prefixed with `agent:` (mirroring the `:` rule). For example, `agent:obsolete-find-signature`. +- `confidence` — capped at `medium`. Without a knowledge-file citation there is no authoritative basis for `high` confidence. +- `message` — non-empty and self-contained. It MUST describe the issue and a concrete recommendation, since a consumer rendering the finding has no knowledge-file footer to fall back on. + +Agent findings are emitted **only by super-skills** (the `al-code-review` super-skill is the canonical example). Leaf sub-skills MUST NOT emit agent findings: a leaf's job is to evaluate one knowledge subset, and a finding it cannot cite from that subset is out of scope for it. Before emitting an agent finding, a super-skill MUST validate the candidate against the BCQuality knowledge it has already loaded for the task — if a knowledge file matches, the candidate is upgraded to a knowledge-backed finding (and merged or deduplicated against any sub-skill output that already covers the same concern); if a knowledge file explicitly contradicts the candidate, it is suppressed. + +Consumers that render output MAY treat agent findings differently from knowledge-backed findings (for example, by labelling them and routing them to a separate review domain). The `from-sub-skill: "agent"` marker is the contract they rely on. + **`findings[].severity`** — see the taxonomy below. **`findings[].message`** — human-readable explanation of the finding. Single short paragraph. No markdown formatting assumptions. @@ -150,11 +162,11 @@ Findings without a `location` are permitted (for example, repository-wide observ - `path` (required) — repo-relative path to the knowledge file, forward slashes. - `sha` (optional) — commit SHA the skill read when producing the finding. Consumers SHOULD include `sha` when the skill was invoked with a specific repo state. -The first reference is the **primary** reference: the knowledge file the finding most directly cites. Additional references provide supporting context and are not ranked. `references` MAY be empty for findings the skill generates without a knowledge-file citation. +The first reference is the **primary** reference: the knowledge file the finding most directly cites. Additional references provide supporting context and are not ranked. `references` MAY be empty only for **agent findings** (see the `findings[].id` section above for the full encoding); any other finding MUST have at least one reference. **`findings[].confidence`** — the skill's confidence that the finding is a true positive, given the evidence it evaluated. Not applicability confidence, not severity confidence. Values: `high`, `medium`, `low`. -**`findings[].from-sub-skill`** — optional. Set only by super-skills. The `skill.id` of the sub-skill that produced the finding. Absent on findings produced directly by the emitting skill. +**`findings[].from-sub-skill`** — optional. Set only by super-skills. The `skill.id` of the sub-skill that produced the finding, or the literal string `"agent"` for an agent finding the super-skill produced from its own reasoning. Absent on findings produced directly by a leaf skill. **`suppressed`** — MUST list every knowledge file that was discarded due to layer precedence or consumer configuration, whenever that file would otherwise have contributed to the worklist. Each entry contains: From a9f3c508636533a32cc85431eb6dc4999e1dfed1 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 21 May 2026 09:53:09 +0200 Subject: [PATCH 15/15] Regenerate microsoft/knowledge from upstream BCApps instructions The previous LLM-generated knowledge files contained factual hallucinations. The most visible was the claim that `FindFirst` / `FindLast` "forces a full-table scan" on an unfiltered record - it does not; those APIs return a single row via the current key. Other inaccuracies the audit found and fixed: * `FindSet(true)` was described as "taking a LockTable". The correct upstream phrasing is that `FindSet(true)` sets `ReadIsolation::UpdLock` on the read. UpdLock and LockTable are related but distinct mechanisms. * The list of production-scale tables had been invented beyond the upstream source (e.g. "Detailed Cust. Ledg. Entry") without a citation. The regenerated list matches the ten tables upstream lists with their P95 row counts. * `SetLoadFields` guidance had been augmented with an extra mechanism claim ("the database resolves the filter using the index without hydrating the value") not present in upstream. Approach: full regeneration of `microsoft/knowledge/` from the six upstream BCApps Code Review instruction files, with Microsoft Learn / the AL language reference as a secondary source. Every claim in every regenerated file is anchored to a verbatim upstream quote (or a Learn URL); the audit trail lives in artifacts/trace-.json on the session workspace. The PR #11 transaction/error-handling cluster is preserved verbatim: * performance/understand-implicit-transaction-boundary.md * performance/codeunit-run-as-atomic-sub-operation.{md,good.al,bad.al} * performance/codeunit-run-requires-prior-commit-inside-transaction.{md,good.al,bad.al} * performance/use-tryfunction-for-error-catching-not-rollback.{md,good.al,bad.al} * performance/avoid-commit-inside-loops.{md,good.al,bad.al} * security/commitbehavior-attribute-scopes-explicit-commits.{md,good.al,bad.al} * testing/transactionmodel-attribute-governs-test-transactions.{md,good.al,bad.al} These articles already cite Microsoft Learn and were carefully cross-referenced; the regeneration skips their topics rather than duplicating them. File counts after regeneration: performance 35 .md (5 preserved + 30 new) privacy 17 .md security 18 .md (1 preserved + 17 new) style 33 .md testing 1 .md (preserved) ui 19 .md upgrade 18 .md Total 141 atomic knowledge files, each strictly one rule. All pass .github/scripts/validate_frontmatter.py with 0 errors and 0 warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../add-sift-keys-for-flowfields.good.al | 10 ----- .../add-sift-keys-for-flowfields.md | 25 ----------- ...dloadfields-in-report-onpredataitem.bad.al | 14 ++++++ ...oadfields-in-report-onpredataitem.good.al} | 4 +- .../addloadfields-in-report-onpredataitem.md | 26 +++++++++++ ...and-migration-pages-tolerate-lower-perf.md | 22 ++++++++++ ... => apply-filters-before-iterating.bad.al} | 8 ++-- ...=> apply-filters-before-iterating.good.al} | 7 +-- .../apply-filters-before-iterating.md | 26 +++++++++++ .../apply-guards-before-get.bad.al | 14 ++++++ ...bad.al => apply-guards-before-get.good.al} | 11 ++--- .../performance/apply-guards-before-get.md | 26 +++++++++++ .../avoid-calcfields-in-loops.bad.al | 18 -------- .../avoid-calcfields-in-loops.good.al | 18 -------- .../performance/avoid-calcfields-in-loops.md | 29 ------------- .../avoid-findfirst-with-next.bad.al | 14 ------ .../performance/avoid-findfirst-with-next.md | 25 ----------- ...void-get-inside-loop-on-large-table.bad.al | 15 +++++++ ...oid-get-inside-loop-on-large-table.good.al | 15 +++++++ .../avoid-get-inside-loop-on-large-table.md | 26 +++++++++++ .../avoid-recordref-in-hot-loop.bad.al | 22 ++++++++++ .../avoid-recordref-in-hot-loop.good.al | 16 +++++++ .../avoid-recordref-in-hot-loop.md | 26 +++++++++++ ...dant-get-when-record-already-loaded.bad.al | 21 +++++++++ ...ant-get-when-record-already-loaded.good.al | 18 ++++++++ ...edundant-get-when-record-already-loaded.md | 26 +++++++++++ ...id-user-interaction-in-transactions.bad.al | 11 ----- ...d-user-interaction-in-transactions.good.al | 14 ------ .../avoid-user-interaction-in-transactions.md | 29 ------------- ...id-user-prompts-inside-transactions.bad.al | 18 ++++++++ ...d-user-prompts-inside-transactions.good.al | 18 ++++++++ .../avoid-user-prompts-inside-transactions.md | 26 +++++++++++ ...blob-fields-are-not-cached-prefer-media.md | 26 ----------- ...csums-instead-of-calcfields-in-loop.bad.al | 15 +++++++ ...sums-instead-of-calcfields-in-loop.good.al | 11 +++++ .../calcsums-instead-of-calcfields-in-loop.md | 26 +++++++++++ .../combine-multiple-modifyall-calls.good.al | 16 ------- .../combine-multiple-modifyall-calls.md | 26 ----------- ...-not-flag-performance-on-bounded-tables.md | 22 ---------- ...ot-locktable-in-read-only-procedure.bad.al | 10 +++++ ...t-locktable-in-read-only-procedure.good.al | 14 ++++++ ...do-not-locktable-in-read-only-procedure.md | 26 +++++++++++ .../do-not-modify-in-onaftergetrecord.bad.al | 17 ++++++++ .../do-not-modify-in-onaftergetrecord.good.al | 18 ++++++++ .../do-not-modify-in-onaftergetrecord.md | 26 +++++++++++ ...-modify-records-in-onaftergetrecord.bad.al | 15 ------- ...modify-records-in-onaftergetrecord.good.al | 35 --------------- ...-not-modify-records-in-onaftergetrecord.md | 26 ----------- ...-re-get-rec-inside-onaftergetrecord.bad.al | 34 --------------- ...re-get-rec-inside-onaftergetrecord.good.al | 30 ------------- ...-not-re-get-rec-inside-onaftergetrecord.md | 26 ----------- ...-sourcetabletemporary-from-api-page.bad.al | 12 ++++++ ...sourcetabletemporary-from-api-page.good.al | 12 ++++++ ...move-sourcetabletemporary-from-api-page.md | 26 +++++++++++ ...-flowfield-calcformula-to-larger-tables.md | 22 ---------- .../performance/filter-before-find.md | 27 ------------ ...indset-true-applies-updlock-on-read.bad.al | 25 +++++++++++ ...ndset-true-applies-updlock-on-read.good.al | 23 ++++++++++ .../findset-true-applies-updlock-on-read.md | 26 +++++++++++ ...eld-source-key-needs-sumindexfields.bad.al | 15 +++++++ ...ld-source-key-needs-sumindexfields.good.al | 23 ++++++++++ ...owfield-source-key-needs-sumindexfields.md | 26 +++++++++++ .../guard-before-get-not-after.good.al | 16 ------- .../performance/guard-before-get-not-after.md | 26 ----------- ...rd-event-subscribers-before-db-call.bad.al | 18 ++++++++ ...d-event-subscribers-before-db-call.good.al | 19 ++++++++ .../guard-event-subscribers-before-db-call.md | 26 +++++++++++ ...den-flowfields-still-calculate-on-pages.md | 24 ----------- .../keep-event-subscribers-lightweight.bad.al | 12 ------ ...keep-event-subscribers-lightweight.good.al | 20 --------- .../keep-event-subscribers-lightweight.md | 29 ------------- ...p-oncompanyopen-subscribers-lightweight.md | 26 ----------- ...letemporary-on-api-and-background-pages.md | 22 ---------- ...e-applies-to-whole-table-in-transaction.md | 24 ----------- ...qlindex-false-breaks-flowfield-sift.bad.al | 26 +++++++++++ ...ainsqlindex-false-breaks-flowfield-sift.md | 24 +++++++++++ .../maintainsqlindex-false-disables-sift.md | 22 ---------- .../only-fetch-records-you-use.bad.al | 10 ----- .../only-fetch-records-you-use.good.al | 10 ----- .../performance/only-fetch-records-you-use.md | 29 ------------- .../pair-findset-with-next-loop.bad.al | 13 ++++++ .../pair-findset-with-next-loop.good.al | 18 ++++++++ .../pair-findset-with-next-loop.md | 26 +++++++++++ ...-to-insert-when-trigger-not-needed.good.al | 21 +++++++++ ...false-to-insert-when-trigger-not-needed.md | 24 +++++++++++ ...ionary-over-temporary-table-for-lookups.md | 22 ++++++++++ ...prefer-direct-record-over-recordref.bad.al | 20 --------- ...refer-direct-record-over-recordref.good.al | 12 ------ .../prefer-direct-record-over-recordref.md | 31 ------------- ...prefer-get-for-primary-key-lookups.good.al | 10 ----- .../prefer-get-for-primary-key-lookups.md | 29 ------------- ...refer-modifyall-over-per-row-modify.bad.al | 15 +++++++ ...fer-modifyall-over-per-row-modify.good.al} | 13 ++++-- .../prefer-modifyall-over-per-row-modify.md | 26 +++++++++++ ...disolation-over-locktable-for-reads.bad.al | 13 ++++++ ...isolation-over-locktable-for-reads.good.al | 11 +++++ ...-readisolation-over-locktable-for-reads.md | 26 +++++++++++ ...ion-scale-tables-warrant-extra-analysis.md | 22 ++++++++++ .../query-objects-bypass-primary-key-cache.md | 26 ----------- .../set-current-key-to-match-filters.good.al | 9 ---- .../set-current-key-to-match-filters.md | 27 ------------ ...currentkey-aligns-key-with-filters.good.al | 15 +++++++ .../setcurrentkey-aligns-key-with-filters.md | 24 +++++++++++ ...etup-tables-need-no-access-optimization.md | 22 ++++++++++ ...fields-on-narrow-tables-and-short-loops.md | 22 ---------- ...-and-write-paths-to-avoid-locktable.bad.al | 14 ------ ...and-write-paths-to-avoid-locktable.good.al | 18 -------- ...only-and-write-paths-to-avoid-locktable.md | 28 ------------ ...rs-disable-bulk-modifyall-and-deleteall.md | 24 ----------- .../temporary-tables-have-no-database-cost.md | 22 ++++++++++ ...ledger-entry-tables-as-production-scale.md | 22 ---------- ...ggers-and-media-field-regress-modifyall.md | 22 ++++++++++ ...framework-to-measure-insert-performance.md | 26 ----------- .../use-addloadfields-in-report-layouts.md | 27 ------------ .../use-calcsums-for-flowfield-totals.bad.al | 14 ------ .../use-calcsums-for-flowfield-totals.good.al | 12 ------ .../use-calcsums-for-flowfield-totals.md | 29 ------------- ...nary-for-temporary-identity-lookups.bad.al | 16 ------- ...ary-for-temporary-identity-lookups.good.al | 13 ------ ...ctionary-for-temporary-identity-lookups.md | 26 ----------- .../use-findset-readonly-by-default.bad.al | 10 ----- .../use-findset-readonly-by-default.good.al | 10 ----- .../use-findset-readonly-by-default.md | 27 ------------ .../performance/use-findset-with-next.bad.al | 10 ----- .../performance/use-findset-with-next.good.al | 10 ----- .../performance/use-findset-with-next.md | 29 ------------- ...d-of-findfirst-on-full-primary-key.bad.al} | 6 +-- ...d-of-findfirst-on-full-primary-key.good.al | 10 +++++ ...nstead-of-findfirst-on-full-primary-key.md | 26 +++++++++++ ...nsert-false-when-skipping-triggers.good.al | 12 ------ ...use-insert-false-when-skipping-triggers.md | 27 ------------ .../use-isempty-for-existence-check.bad.al | 17 ++++++++ .../use-isempty-for-existence-check.good.al | 11 +++++ .../use-isempty-for-existence-check.md | 26 +++++++++++ .../use-isempty-for-existence-checks.bad.al | 11 ----- .../use-isempty-for-existence-checks.good.al | 11 ----- .../use-isempty-for-existence-checks.md | 29 ------------- ...e-setloadfields-for-partial-records.bad.al | 18 ++++---- ...-setloadfields-for-partial-records.good.al | 22 +++++++--- .../use-setloadfields-for-partial-records.md | 13 +++--- ...gle-instance-codeunits-for-caching.good.al | 17 -------- ...e-single-instance-codeunits-for-caching.md | 27 ------------ ...orary-tables-for-intermediate-data.good.al | 16 ------- ...-temporary-tables-for-intermediate-data.md | 27 ------------ ...extbuilder-for-loop-string-assembly.bad.al | 14 ------ ...xtbuilder-for-loop-string-assembly.good.al | 14 ------ ...se-textbuilder-for-loop-string-assembly.md | 26 ----------- ...ilder-for-string-concatenation-in-loops.md | 22 ++++++++++ ...id-strsubstno-prebuild-before-error.bad.al | 11 +++++ ...d-strsubstno-prebuild-before-error.good.al | 9 ++++ .../avoid-strsubstno-prebuild-before-error.md | 26 +++++++++++ ...ssify-data-at-migration-destination.bad.al | 14 ------ ...sify-data-at-migration-destination.good.al | 14 ------ .../classify-data-at-migration-destination.md | 26 ----------- ...-classification-is-table-field-property.md | 22 ++++++++++ ...assification-required-on-pii-fields.bad.al | 11 +++++ ...ssification-required-on-pii-fields.good.al | 11 +++++ ...a-classification-required-on-pii-fields.md | 26 +++++++++++ ...lassification-is-a-table-field-property.md | 22 ---------- ...om-isolated-storage-to-plain-fields.bad.al | 13 ------ ...m-isolated-storage-to-plain-fields.good.al | 10 ----- ...i-from-isolated-storage-to-plain-fields.md | 26 ----------- ...ct-substitution-safe-for-telemetry.good.al | 10 +++++ ...-direct-substitution-safe-for-telemetry.md | 24 +++++++++++ ...-logged-to-telemetry-message-is-not.bad.al | 16 ------- ...logged-to-telemetry-message-is-not.good.al | 15 ------- ...r-is-logged-to-telemetry-message-is-not.md | 26 ----------- .../error-vs-message-telemetry-logging.md | 22 ++++++++++ ...retelemetry-customdimensions-no-pii.bad.al | 12 ++++++ ...etelemetry-customdimensions-no-pii.good.al | 10 +++++ ...eaturetelemetry-customdimensions-no-pii.md | 26 +++++++++++ ...lter-classification-systemmetadata.good.al | 18 ++++++++ ...lowfilter-classification-systemmetadata.md | 24 +++++++++++ .../flowfields-auto-inherit-systemmetadata.md | 22 ---------- ...rrortext-customer-content-in-errors.bad.al | 17 ++++++++ ...rortext-customer-content-in-errors.good.al | 16 +++++++ ...asterrortext-customer-content-in-errors.md | 26 +++++++++++ ...in-memory-data-is-not-a-privacy-concern.md | 22 ---------- .../in-memory-data-not-a-privacy-concern.md | 22 ++++++++++ ...-out-of-featuretelemetry-dimensions.bad.al | 14 ------ ...out-of-featuretelemetry-dimensions.good.al | 13 ------ ...data-out-of-featuretelemetry-dimensions.md | 26 ----------- .../migration-destination-classification.md | 22 ++++++++++ .../no-pii-in-telemetry-message-string.bad.al | 21 +++++++++ ...no-pii-in-telemetry-message-string.good.al | 15 +++++++ .../no-pii-in-telemetry-message-string.md | 26 +++++++++++ ...erited-dataclassification-per-field.bad.al | 18 -------- ...rited-dataclassification-per-field.good.al | 23 ---------- ...-inherited-dataclassification-per-field.md | 26 ----------- .../page-display-is-not-a-privacy-concern.md | 22 ++++++++++ ...permitted-data-is-not-a-privacy-concern.md | 22 ---------- ...-consent-for-external-data-transfer.bad.al | 13 ++++++ ...consent-for-external-data-transfer.good.al | 22 ++++++++++ ...tice-consent-for-external-data-transfer.md | 26 +++++++++++ ...on-in-privacy-notice-registrations.good.al | 11 +++++ ...gration-in-privacy-notice-registrations.md | 24 +++++++++++ ...ce-consent-before-outgoing-requests.bad.al | 14 ------ ...e-consent-before-outgoing-requests.good.al | 20 --------- ...notice-consent-before-outgoing-requests.md | 26 ----------- .../resolve-tobeclassified-before-release.md | 10 ++--- ...e-getlasterrortext-before-telemetry.bad.al | 16 ------- ...-getlasterrortext-before-telemetry.good.al | 15 ------- ...itize-getlasterrortext-before-telemetry.md | 26 ----------- ...message-requires-dataclassification.bad.al | 7 +++ ...essage-requires-dataclassification.good.al | 8 ++++ ...-logmessage-requires-dataclassification.md | 26 +++++++++++ ...ssification-on-every-telemetry-call.bad.al | 16 ------- ...sification-on-every-telemetry-call.good.al | 17 -------- ...aclassification-on-every-telemetry-call.md | 26 ----------- ...eaks-error-telemetry-classification.bad.al | 14 ------ ...aks-error-telemetry-classification.good.al | 11 ----- ...d-breaks-error-telemetry-classification.md | 26 ----------- ...level-data-classification-cascades.good.al | 16 +++++++ ...able-level-data-classification-cascades.md | 24 +++++++++++ .../al-has-no-built-in-htmlencode.bad.al | 7 +++ .../al-has-no-built-in-htmlencode.good.al | 19 ++++++++ .../security/al-has-no-built-in-htmlencode.md | 22 ++++++++++ ...mpose-secrets-with-secretstrsubstno.bad.al | 9 ---- ...pose-secrets-with-secretstrsubstno.good.al | 7 --- .../compose-secrets-with-secretstrsubstno.md | 29 ------------- ...validatetablerelation-on-user-input.bad.al | 16 ------- ...alidatetablerelation-on-user-input.good.al | 24 ----------- ...ble-validatetablerelation-on-user-input.md | 26 ----------- ...-sensitive-data-in-event-publishers.bad.al | 19 -------- ...sensitive-data-in-event-publishers.good.al | 23 ---------- ...pose-sensitive-data-in-event-publishers.md | 29 ------------- ...hardcode-environment-specific-guids.bad.al | 14 ------ ...ardcode-environment-specific-guids.good.al | 16 ------- ...not-hardcode-environment-specific-guids.md | 26 ----------- ...-least-privilege-in-permission-sets.bad.al | 6 --- ...least-privilege-in-permission-sets.good.al | 9 ---- ...llow-least-privilege-in-permission-sets.md | 29 ------------- ...ext-storage-is-privacy-not-security.bad.al | 10 +++++ ...rortext-storage-is-privacy-not-security.md | 22 ++++++++++ ...ect-permissions-for-elevated-access.bad.al | 4 ++ ...ct-permissions-for-elevated-access.good.al | 4 ++ ...ndirect-permissions-for-elevated-access.md | 22 ++++++++++ .../inherent-permissions-minimal-grant.bad.al | 20 +++++++++ ...inherent-permissions-minimal-grant.good.al | 19 ++++++++ .../inherent-permissions-minimal-grant.md | 22 ++++++++++ ...rationevent-must-not-expose-secrets.bad.al | 7 +++ ...ationevent-must-not-expose-secrets.good.al | 7 +++ ...ntegrationevent-must-not-expose-secrets.md | 22 ++++++++++ ...-parameter-bypasses-security-guards.bad.al | 19 ++++++++ ...parameter-bypasses-security-guards.good.al | 22 ++++++++++ ...-var-parameter-bypasses-security-guards.md | 22 ++++++++++ ...ge-access-must-be-local-or-internal.bad.al | 16 +++++++ ...e-access-must-be-local-or-internal.good.al | 15 +++++++ ...torage-access-must-be-local-or-internal.md | 22 ++++++++++ ...storage-datascope-module-vs-company.bad.al | 15 +++++++ ...torage-datascope-module-vs-company.good.al | 20 +++++++++ ...atedstorage-datascope-module-vs-company.md | 22 ++++++++++ ...e-setencrypted-for-sensitive-values.bad.al | 7 +++ ...-setencrypted-for-sensitive-values.good.al | 17 ++++++++ ...orage-setencrypted-for-sensitive-values.md | 22 ++++++++++ ...-recordref-open-callers-non-public.good.al | 12 ------ .../keep-recordref-open-callers-non-public.md | 26 ----------- .../never-hardcode-secrets-in-al.bad.al | 10 ----- .../never-hardcode-secrets-in-al.good.al | 12 ------ .../security/never-hardcode-secrets-in-al.md | 29 ------------- ...required-when-unwrapping-secrettext.bad.al | 19 ++++++++ ...equired-when-unwrapping-secrettext.good.al | 21 +++++++++ ...ble-required-when-unwrapping-secrettext.md | 22 ++++++++++ ...ermission-set-avoid-wildcard-grants.bad.al | 10 +++++ ...rmission-set-avoid-wildcard-grants.good.al | 8 ++++ .../permission-set-avoid-wildcard-grants.md | 22 ++++++++++ ...-azure-key-vault-for-production-secrets.md | 25 ----------- ...th-caller-table-must-not-be-public.bad.al} | 2 +- ...th-caller-table-must-not-be-public.good.al | 29 +++++++++++++ ...en-with-caller-table-must-not-be-public.md | 22 ++++++++++ ...retstrsubstno-for-composing-secrets.bad.al | 12 ++++++ ...etstrsubstno-for-composing-secrets.good.al | 12 ++++++ .../secretstrsubstno-for-composing-secrets.md | 22 ++++++++++ .../secrettext-for-credentials.bad.al | 22 ++++++++++ .../secrettext-for-credentials.good.al | 16 +++++++ .../security/secrettext-for-credentials.md | 22 ++++++++++ .../secrettext-with-httpclient.bad.al | 23 ++++++++++ .../secrettext-with-httpclient.good.al | 28 ++++++++++++ .../security/secrettext-with-httpclient.md | 22 ++++++++++ ...ect-permissions-for-elevated-access.bad.al | 7 --- ...ct-permissions-for-elevated-access.good.al | 34 --------------- ...ndirect-permissions-for-elevated-access.md | 29 ------------- ...permissions-to-grant-minimal-access.bad.al | 13 ------ ...ermissions-to-grant-minimal-access.good.al | 28 ------------ ...ent-permissions-to-grant-minimal-access.md | 29 ------------- ...rage-for-module-and-company-secrets.bad.al | 18 -------- ...age-for-module-and-company-secrets.good.al | 14 ------ ...-storage-for-module-and-company-secrets.md | 29 ------------- ...-nondebuggable-when-parsing-secrets.bad.al | 21 --------- ...nondebuggable-when-parsing-secrets.good.al | 21 --------- .../use-nondebuggable-when-parsing-secrets.md | 29 ------------- .../use-secrettext-for-credentials.bad.al | 13 ------ .../use-secrettext-for-credentials.good.al | 14 ------ .../use-secrettext-for-credentials.md | 27 ------------ .../use-secrettext-with-httpclient.bad.al | 12 ------ .../use-secrettext-with-httpclient.good.al | 15 ------- .../use-secrettext-with-httpclient.md | 27 ------------ ...configurable-urls-before-http-calls.bad.al | 10 ----- ...onfigurable-urls-before-http-calls.good.al | 17 -------- ...ser-configurable-urls-before-http-calls.md | 26 ----------- .../validate-user-configurable-urls.bad.al | 20 +++++++++ .../validate-user-configurable-urls.good.al | 24 +++++++++++ .../validate-user-configurable-urls.md | 22 ++++++++++ ...tetablerelation-false-on-user-input.bad.al | 11 +++++ ...etablerelation-false-on-user-input.good.al | 26 +++++++++++ ...lidatetablerelation-false-on-user-input.md | 22 ++++++++++ .../abouttitle-abouttext-teaching-tips.bad.al | 5 +++ ...abouttitle-abouttext-teaching-tips.good.al | 15 +++++++ .../abouttitle-abouttext-teaching-tips.md | 28 ++++++++++++ .../api-page-camelcase-properties.bad.al | 10 +++++ ... => api-page-camelcase-properties.good.al} | 7 +-- .../style/api-page-camelcase-properties.md | 26 +++++++++++ .../style/api-page-delayedinsert-true.bad.al | 10 +++++ .../style/api-page-delayedinsert-true.good.al | 11 +++++ .../style/api-page-delayedinsert-true.md | 26 +++++++++++ ...-page-entity-naming-singular-plural.bad.al | 10 +++++ ...page-entity-naming-singular-plural.good.al | 23 ++++++++++ .../api-page-entity-naming-singular-plural.md | 26 +++++++++++ .../style/api-page-version-format.bad.al | 10 +++++ .../style/api-page-version-format.good.al | 23 ++++++++++ .../style/api-page-version-format.md | 26 +++++++++++ .../apply-approved-label-suffixes.bad.al | 17 -------- .../apply-approved-label-suffixes.good.al | 20 --------- .../style/apply-approved-label-suffixes.md | 26 ----------- .../begin-on-same-line-as-then-else-do.bad.al | 14 ++++++ ...begin-on-same-line-as-then-else-do.good.al | 24 +++++++++++ .../begin-on-same-line-as-then-else-do.md | 26 +++++++++++ .../block-keywords-start-new-line.bad.al | 15 +++++++ .../block-keywords-start-new-line.good.al | 23 ++++++++++ .../style/block-keywords-start-new-line.md | 26 +++++++++++ .../caption-required-on-page-fields.bad.al | 13 ++++++ .../caption-required-on-page-fields.good.al | 17 ++++++++ .../style/caption-required-on-page-fields.md | 28 ++++++++++++ ...se-action-on-line-after-possibility.bad.al | 16 +++++++ ...e-action-on-line-after-possibility.good.al | 21 +++++++++ .../case-action-on-line-after-possibility.md | 26 +++++++++++ ...-parameters-directly-not-strsubstno.bad.al | 15 +++++++ ...parameters-directly-not-strsubstno.good.al | 13 ++++++ ...sses-parameters-directly-not-strsubstno.md | 26 +++++++++++ ...-subscriber-param-names-match-publisher.md | 22 ++++++++++ ...tion-not-fieldname-in-user-messages.bad.al | 13 ++++++ ...ion-not-fieldname-in-user-messages.good.al | 13 ++++++ ...dcaption-not-fieldname-in-user-messages.md | 26 +++++++++++ .../style/file-name-object-type-pattern.md | 24 +++++++++++ .../style/follow-api-page-naming-rules.bad.al | 22 ---------- .../style/follow-api-page-naming-rules.md | 26 ----------- .../function-call-parentheses-required.bad.al | 11 +++++ ...function-call-parentheses-required.good.al | 11 +++++ .../function-call-parentheses-required.md | 26 +++++++++++ ...comment-on-labels-with-placeholders.bad.al | 15 ------- ...omment-on-labels-with-placeholders.good.al | 14 ------ ...ude-comment-on-labels-with-placeholders.md | 26 ----------- ...label-comment-explains-placeholders.bad.al | 11 +++++ ...abel-comment-explains-placeholders.good.al | 12 ++++++ .../label-comment-explains-placeholders.md | 26 +++++++++++ .../label-locked-for-non-translatable.bad.al | 7 +++ .../label-locked-for-non-translatable.good.al | 8 ++++ .../label-locked-for-non-translatable.md | 26 +++++++++++ .../style/label-suffix-approved-list.bad.al | 14 ++++++ .../style/label-suffix-approved-list.good.al | 15 +++++++ .../style/label-suffix-approved-list.md | 26 +++++++++++ .../style/lowercase-reserved-keywords.bad.al | 14 ++++++ .../style/lowercase-reserved-keywords.good.al | 14 ++++++ .../style/lowercase-reserved-keywords.md | 28 ++++++++++++ ...ptioncaption-count-to-optionmembers.bad.al | 22 ---------- ...ch-optioncaption-count-to-optionmembers.md | 26 ----------- .../name-files-as-object-dot-type-dot-al.md | 22 ---------- .../named-invocations-not-object-ids.bad.al | 12 ++++++ .../named-invocations-not-object-ids.good.al | 12 ++++++ .../style/named-invocations-not-object-ids.md | 26 +++++++++++ ...o-begin-end-around-single-statement.bad.al | 11 +++++ ...-begin-end-around-single-statement.good.al | 10 +++++ .../no-begin-end-around-single-statement.md | 26 +++++++++++ ...no-else-after-terminating-statement.bad.al | 13 ++++++ ...o-else-after-terminating-statement.good.al | 12 ++++++ .../no-else-after-terminating-statement.md | 26 +++++++++++ .../no-space-before-method-parenthesis.bad.al | 11 +++++ ...no-space-before-method-parenthesis.good.al | 11 +++++ .../no-space-before-method-parenthesis.md | 26 +++++++++++ .../style/object-name-30-char-limit.md | 22 ++++++++++ ...on-required-and-matches-membercount.bad.al | 17 ++++++++ ...-required-and-matches-membercount.good.al} | 21 ++++----- ...aption-required-and-matches-membercount.md | 26 +++++++++++ .../page-name-must-match-source-table.md | 24 +++++++++++ ...ers-directly-to-error-no-strsubstno.bad.al | 13 ------ ...rs-directly-to-error-no-strsubstno.good.al | 11 ----- ...ameters-directly-to-error-no-strsubstno.md | 26 ----------- ...emporary-record-variables-with-temp.bad.al | 17 -------- ...mporary-record-variables-with-temp.good.al | 16 ------- ...ix-temporary-record-variables-with-temp.md | 26 ----------- ...quire-parentheses-on-function-calls.bad.al | 13 ------ ...uire-parentheses-on-function-calls.good.al | 12 ------ .../require-parentheses-on-function-calls.md | 26 ----------- .../single-space-after-not-operator.bad.al | 11 +++++ .../single-space-after-not-operator.good.al | 11 +++++ .../style/single-space-after-not-operator.md | 26 +++++++++++ ...ingle-space-around-binary-operators.bad.al | 12 ++++++ ...ngle-space-around-binary-operators.good.al | 12 ++++++ .../single-space-around-binary-operators.md | 26 +++++++++++ .../temporary-variable-temp-prefix.bad.al | 12 ++++++ .../temporary-variable-temp-prefix.good.al | 12 ++++++ .../style/temporary-variable-temp-prefix.md | 26 +++++++++++ .../style/this-keyword-in-codeunits.bad.al | 14 ++++++ .../style/this-keyword-in-codeunits.good.al | 14 ++++++ .../style/this-keyword-in-codeunits.md | 26 +++++++++++ .../tooltip-required-on-page-fields.bad.al} | 10 ++--- .../tooltip-required-on-page-fields.good.al} | 12 +++--- .../style/tooltip-required-on-page-fields.md | 28 ++++++++++++ ...n-and-tablecaption-in-user-messages.bad.al | 12 ------ ...-and-tablecaption-in-user-messages.good.al | 13 ------ ...ption-and-tablecaption-in-user-messages.md | 26 ----------- ...d-invocations-instead-of-object-ids.bad.al | 10 ----- ...-invocations-instead-of-object-ids.good.al | 9 ---- ...named-invocations-instead-of-object-ids.md | 26 ----------- .../use-this-keyword-in-codeunits.bad.al | 15 ------- .../use-this-keyword-in-codeunits.good.al | 21 --------- .../style/use-this-keyword-in-codeunits.md | 26 ----------- .../variable-declaration-order-by-type.bad.al | 13 ++++++ ...variable-declaration-order-by-type.good.al | 13 ++++++ .../variable-declaration-order-by-type.md | 26 +++++++++++ .../variable-name-must-not-shadow.bad.al | 19 ++++++++ .../variable-name-must-not-shadow.good.al | 19 ++++++++ .../style/variable-name-must-not-shadow.md | 26 +++++++++++ .../xmldoc-for-public-library-procedures.md | 24 +++++++++++ ...-are-imperative-and-end-with-period.bad.al | 26 ----------- ...are-imperative-and-end-with-period.good.al | 25 ----------- ...tips-are-imperative-and-end-with-period.md | 26 ----------- .../knowledge/ui/avoid-banned-ui-terms.md | 22 ---------- ...tion-noun-phrase-vs-sentence-phrase.bad.al | 21 --------- ...ion-noun-phrase-vs-sentence-phrase.good.al | 27 ------------ ...lization-noun-phrase-vs-sentence-phrase.md | 26 ----------- ...cessibility-is-developer-responsibility.md | 20 +++++++++ .../control-add-in-has-no-bc-color-tokens.md | 18 ++++++++ ...cosmetic-styles-need-no-textual-context.md | 28 ++++++++++++ ...tart-with-specifies-and-end-with-period.md | 26 ----------- ...d.al => grid-data-table-heuristic.good.al} | 17 +++++--- .../knowledge/ui/grid-data-table-heuristic.md | 28 ++++++++++++ ...n-quality-is-not-an-accessibility-issue.md | 20 +++++++++ ...group-labeled-first-child-exception.bad.al | 22 ++++++++++ ...oup-labeled-first-child-exception.good.al} | 13 +++--- .../ui/group-labeled-first-child-exception.md | 32 ++++++++++++++ ...n-false-outside-grid-is-not-a-violation.md | 20 +++++++++ .../keep-captions-on-editable-fields.bad.al | 14 ------ .../ui/keep-captions-on-editable-fields.md | 26 ----------- .../ui/layout-table-with-captions-is-valid.md | 20 +++++++++ ...y-review-control-addin-ui-accessibility.md | 22 ---------- microsoft/knowledge/ui/no-nested-grids.bad.al | 33 ++++++++++++++ microsoft/knowledge/ui/no-nested-grids.md | 22 ++++++++++ ...-on-non-editable-fields-renders-as-link.md | 20 +++++++++ ...de-text-meaning-for-semantic-styles.bad.al | 19 -------- ...e-text-meaning-for-semantic-styles.good.al | 19 -------- ...rovide-text-meaning-for-semantic-styles.md | 26 ----------- .../ui/respect-ui-text-character-limits.md | 22 ---------- ...mantic-style-in-cuegroup-exception.good.al | 31 +++++++++++++ .../semantic-style-in-cuegroup-exception.md | 22 ++++++++++ ...es-need-independent-textual-meaning.bad.al | 27 ++++++++++++ ...s-need-independent-textual-meaning.good.al | 28 ++++++++++++ ...styles-need-independent-textual-meaning.md | 34 +++++++++++++++ ...lse-allowed-on-non-editable-fields.good.al | 18 ++++++++ ...on-false-allowed-on-non-editable-fields.md | 22 ++++++++++ ...aption-in-promptdialog-prompt-area.good.al | 31 +++++++++++++ ...how-caption-in-promptdialog-prompt-area.md | 22 ++++++++++ .../show-caption-in-repeater-allowed.good.al | 25 +++++++++++ .../ui/show-caption-in-repeater-allowed.md | 22 ++++++++++ .../ui/show-caption-on-editable-fields.bad.al | 27 ++++++++++++ .../show-caption-on-editable-fields.good.al | 16 +++++++ .../ui/show-caption-on-editable-fields.md | 28 ++++++++++++ ...standalone-content-in-layout-table.good.al | 39 +++++++++++++++++ .../ui/standalone-content-in-layout-table.md | 22 ++++++++++ .../ui/style-expr-text-vs-boolean.good.al | 41 ++++++++++++++++++ .../ui/style-expr-text-vs-boolean.md | 25 +++++++++++ ...tent-requires-data-table-conditions.bad.al | 40 +++++++++++++++++ ...r-intent-requires-data-table-conditions.md | 27 ++++++++++++ .../ui/titles-have-no-trailing-punctuation.md | 22 ---------- .../tooltips-describe-teaching-tips-guide.md | 22 ---------- .../tour-tips-do-not-use-action-language.md | 22 ---------- ...se-and-not-ampersand-in-ui-captions.bad.al | 20 --------- ...e-and-not-ampersand-in-ui-captions.good.al | 19 -------- .../use-and-not-ampersand-in-ui-captions.md | 26 ----------- ...rid-data-table-pattern-consistently.bad.al | 24 ----------- ...se-grid-data-table-pattern-consistently.md | 26 ----------- ...xisting-data-before-key-or-type-changes.md | 22 ---------- ...changes-only-on-tables-without-data.bad.al | 15 +++++++ ...hanges-only-on-tables-without-data.good.al | 16 +++++++ ...ing-changes-only-on-tables-without-data.md | 26 +++++++++++ ...-onupgrade-triggers-not-inline-code.bad.al | 14 ------ ...onupgrade-triggers-not-inline-code.good.al | 31 ------------- ...from-onupgrade-triggers-not-inline-code.md | 26 ----------- .../upgrade/datatransfer-for-bulk-init.bad.al | 23 ++++++++++ .../datatransfer-for-bulk-init.good.al | 23 ++++++++++ .../upgrade/datatransfer-for-bulk-init.md | 30 +++++++++++++ ...sfer-skips-triggers-and-subscribers.bad.al | 16 +++++++ ...fer-skips-triggers-and-subscribers.good.al | 16 +++++++ ...transfer-skips-triggers-and-subscribers.md | 28 ++++++++++++ ...-first-install-via-dataversion-zero.bad.al | 15 ------- ...tect-first-install-via-dataversion-zero.md | 26 ----------- ...do-not-block-upgrade-on-data-errors.bad.al | 17 ++++++++ ...o-not-block-upgrade-on-data-errors.good.al | 26 +++++++++++ .../do-not-block-upgrade-on-data-errors.md | 26 +++++++++++ ...ake-external-calls-in-upgrade-codeunits.md | 22 ---------- ...changes-must-be-additive-at-the-end.bad.al | 23 ---------- ...hanges-must-be-additive-at-the-end.good.al | 29 ------------- ...num-changes-must-be-additive-at-the-end.md | 33 -------------- .../enum-values-additive-at-end.bad.al | 11 +++++ .../enum-values-additive-at-end.good.al | 9 ++++ .../upgrade/enum-values-additive-at-end.md | 31 +++++++++++++ ...n-codeunits-from-standard-upgrade-rules.md | 22 ---------- ...irst-install-dataversion-zero-check.bad.al | 13 ++++++ ...st-install-dataversion-zero-check.good.al} | 9 +--- .../first-install-dataversion-zero-check.md | 30 +++++++++++++ .../upgrade/guard-database-reads.bad.al | 20 +++++++++ .../upgrade/guard-database-reads.good.al | 22 ++++++++++ .../knowledge/upgrade/guard-database-reads.md | 26 +++++++++++ ...-database-read-in-upgrade-codeunits.bad.al | 19 -------- ...database-read-in-upgrade-codeunits.good.al | 23 ---------- ...very-database-read-in-upgrade-codeunits.md | 26 ----------- ...formance-impacting-upgrade-triggers.bad.al | 13 ------ ...ormance-impacting-upgrade-triggers.good.al | 25 ----------- ...-performance-impacting-upgrade-triggers.md | 26 ----------- ...igration-codeunits-not-standard-upgrade.md | 24 +++++++++++ ...-does-not-populate-existing-records.bad.al | 14 ------ ...does-not-populate-existing-records.good.al | 43 ------------------- ...alue-does-not-populate-existing-records.md | 26 ----------- ...value-does-not-update-existing-rows.bad.al | 15 +++++++ ...alue-does-not-update-existing-rows.good.al | 43 +++++++++++++++++++ ...initvalue-does-not-update-existing-rows.md | 32 ++++++++++++++ ...inimize-onvalidate-upgrade-triggers.bad.al | 13 ++++++ ...nimize-onvalidate-upgrade-triggers.good.al | 25 +++++++++++ .../minimize-onvalidate-upgrade-triggers.md | 26 +++++++++++ .../no-external-calls-in-upgrade.bad.al | 13 ++++++ .../no-external-calls-in-upgrade.good.al | 17 ++++++++ .../upgrade/no-external-calls-in-upgrade.md | 28 ++++++++++++ ...obsolete-pending-to-removed-staging.bad.al | 14 ++++++ ...bsolete-pending-to-removed-staging.good.al | 29 +++++++++++++ .../obsolete-pending-to-removed-staging.md | 26 +++++++++++ .../obsoletion-requires-reason-and-tag.bad.al | 8 ++++ ...obsoletion-requires-reason-and-tag.good.al | 12 ++++++ .../obsoletion-requires-reason-and-tag.md | 36 ++++++++++++++++ ...ister-upgrade-tags-with-subscribers.bad.al | 16 +++---- ...ster-upgrade-tags-with-subscribers.good.al | 18 ++++---- .../register-upgrade-tags-with-subscribers.md | 12 +++--- ...sential-work-during-upgrade-context.bad.al | 14 ------ ...ential-work-during-upgrade-context.good.al | 15 ------- ...n-essential-work-during-upgrade-context.md | 26 ----------- ...ssential-work-via-execution-context.bad.al | 12 ++++++ ...sential-work-via-execution-context.good.al | 15 +++++++ ...nonessential-work-via-execution-context.md | 28 ++++++++++++ ...rs-call-helpers-not-implementations.bad.al | 12 ++++++ ...s-call-helpers-not-implementations.good.al | 19 ++++++++ ...iggers-call-helpers-not-implementations.md | 28 ++++++++++++ .../upgrade/upgrade-codeunit-subtype.bad.al | 10 +++++ .../upgrade/upgrade-codeunit-subtype.good.al | 17 ++++++++ .../upgrade/upgrade-codeunit-subtype.md | 26 +++++++++++ ...er-for-large-dataset-initialization.bad.al | 22 ---------- ...r-for-large-dataset-initialization.good.al | 31 ------------- ...ansfer-for-large-dataset-initialization.md | 28 ------------ ...use-obsolete-pending-before-removed.bad.al | 11 ----- ...se-obsolete-pending-before-removed.good.al | 13 ------ .../use-obsolete-pending-before-removed.md | 26 ----------- ...use-upgrade-tags-not-version-checks.bad.al | 24 +++++------ ...se-upgrade-tags-not-version-checks.good.al | 16 +++---- .../use-upgrade-tags-not-version-checks.md | 15 ++++--- 562 files changed, 6293 insertions(+), 4869 deletions(-) delete mode 100644 microsoft/knowledge/performance/add-sift-keys-for-flowfields.good.al delete mode 100644 microsoft/knowledge/performance/add-sift-keys-for-flowfields.md create mode 100644 microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al rename microsoft/knowledge/performance/{use-addloadfields-in-report-layouts.good.al => addloadfields-in-report-onpredataitem.good.al} (76%) create mode 100644 microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md create mode 100644 microsoft/knowledge/performance/admin-and-migration-pages-tolerate-lower-perf.md rename microsoft/knowledge/performance/{filter-before-find.bad.al => apply-filters-before-iterating.bad.al} (60%) rename microsoft/knowledge/performance/{filter-before-find.good.al => apply-filters-before-iterating.good.al} (67%) create mode 100644 microsoft/knowledge/performance/apply-filters-before-iterating.md create mode 100644 microsoft/knowledge/performance/apply-guards-before-get.bad.al rename microsoft/knowledge/performance/{guard-before-get-not-after.bad.al => apply-guards-before-get.good.al} (50%) create mode 100644 microsoft/knowledge/performance/apply-guards-before-get.md delete mode 100644 microsoft/knowledge/performance/avoid-calcfields-in-loops.bad.al delete mode 100644 microsoft/knowledge/performance/avoid-calcfields-in-loops.good.al delete mode 100644 microsoft/knowledge/performance/avoid-calcfields-in-loops.md delete mode 100644 microsoft/knowledge/performance/avoid-findfirst-with-next.bad.al delete mode 100644 microsoft/knowledge/performance/avoid-findfirst-with-next.md create mode 100644 microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.bad.al create mode 100644 microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al create mode 100644 microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md create mode 100644 microsoft/knowledge/performance/avoid-recordref-in-hot-loop.bad.al create mode 100644 microsoft/knowledge/performance/avoid-recordref-in-hot-loop.good.al create mode 100644 microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md create mode 100644 microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.bad.al create mode 100644 microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.good.al create mode 100644 microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md delete mode 100644 microsoft/knowledge/performance/avoid-user-interaction-in-transactions.bad.al delete mode 100644 microsoft/knowledge/performance/avoid-user-interaction-in-transactions.good.al delete mode 100644 microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md create mode 100644 microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.bad.al create mode 100644 microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.good.al create mode 100644 microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md delete mode 100644 microsoft/knowledge/performance/blob-fields-are-not-cached-prefer-media.md create mode 100644 microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al create mode 100644 microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al create mode 100644 microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md delete mode 100644 microsoft/knowledge/performance/combine-multiple-modifyall-calls.good.al delete mode 100644 microsoft/knowledge/performance/combine-multiple-modifyall-calls.md delete mode 100644 microsoft/knowledge/performance/do-not-flag-performance-on-bounded-tables.md create mode 100644 microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.bad.al create mode 100644 microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.good.al create mode 100644 microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md create mode 100644 microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.bad.al create mode 100644 microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.good.al create mode 100644 microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md delete mode 100644 microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.bad.al delete mode 100644 microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.good.al delete mode 100644 microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.md delete mode 100644 microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.bad.al delete mode 100644 microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.good.al delete mode 100644 microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.md create mode 100644 microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.bad.al create mode 100644 microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.good.al create mode 100644 microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md delete mode 100644 microsoft/knowledge/performance/do-not-retarget-flowfield-calcformula-to-larger-tables.md delete mode 100644 microsoft/knowledge/performance/filter-before-find.md create mode 100644 microsoft/knowledge/performance/findset-true-applies-updlock-on-read.bad.al create mode 100644 microsoft/knowledge/performance/findset-true-applies-updlock-on-read.good.al create mode 100644 microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md create mode 100644 microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.bad.al create mode 100644 microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.good.al create mode 100644 microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md delete mode 100644 microsoft/knowledge/performance/guard-before-get-not-after.good.al delete mode 100644 microsoft/knowledge/performance/guard-before-get-not-after.md create mode 100644 microsoft/knowledge/performance/guard-event-subscribers-before-db-call.bad.al create mode 100644 microsoft/knowledge/performance/guard-event-subscribers-before-db-call.good.al create mode 100644 microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md delete mode 100644 microsoft/knowledge/performance/hidden-flowfields-still-calculate-on-pages.md delete mode 100644 microsoft/knowledge/performance/keep-event-subscribers-lightweight.bad.al delete mode 100644 microsoft/knowledge/performance/keep-event-subscribers-lightweight.good.al delete mode 100644 microsoft/knowledge/performance/keep-event-subscribers-lightweight.md delete mode 100644 microsoft/knowledge/performance/keep-oncompanyopen-subscribers-lightweight.md delete mode 100644 microsoft/knowledge/performance/keep-sourcetabletemporary-on-api-and-background-pages.md delete mode 100644 microsoft/knowledge/performance/locktable-applies-to-whole-table-in-transaction.md create mode 100644 microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.bad.al create mode 100644 microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md delete mode 100644 microsoft/knowledge/performance/maintainsqlindex-false-disables-sift.md delete mode 100644 microsoft/knowledge/performance/only-fetch-records-you-use.bad.al delete mode 100644 microsoft/knowledge/performance/only-fetch-records-you-use.good.al delete mode 100644 microsoft/knowledge/performance/only-fetch-records-you-use.md create mode 100644 microsoft/knowledge/performance/pair-findset-with-next-loop.bad.al create mode 100644 microsoft/knowledge/performance/pair-findset-with-next-loop.good.al create mode 100644 microsoft/knowledge/performance/pair-findset-with-next-loop.md create mode 100644 microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.good.al create mode 100644 microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md create mode 100644 microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md delete mode 100644 microsoft/knowledge/performance/prefer-direct-record-over-recordref.bad.al delete mode 100644 microsoft/knowledge/performance/prefer-direct-record-over-recordref.good.al delete mode 100644 microsoft/knowledge/performance/prefer-direct-record-over-recordref.md delete mode 100644 microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.good.al delete mode 100644 microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md create mode 100644 microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al rename microsoft/knowledge/performance/{combine-multiple-modifyall-calls.bad.al => prefer-modifyall-over-per-row-modify.good.al} (50%) create mode 100644 microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md create mode 100644 microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al create mode 100644 microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al create mode 100644 microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md create mode 100644 microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md delete mode 100644 microsoft/knowledge/performance/query-objects-bypass-primary-key-cache.md delete mode 100644 microsoft/knowledge/performance/set-current-key-to-match-filters.good.al delete mode 100644 microsoft/knowledge/performance/set-current-key-to-match-filters.md create mode 100644 microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al create mode 100644 microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md create mode 100644 microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md delete mode 100644 microsoft/knowledge/performance/skip-setloadfields-on-narrow-tables-and-short-loops.md delete mode 100644 microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.bad.al delete mode 100644 microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al delete mode 100644 microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md delete mode 100644 microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md create mode 100644 microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md delete mode 100644 microsoft/knowledge/performance/treat-ledger-entry-tables-as-production-scale.md create mode 100644 microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md delete mode 100644 microsoft/knowledge/performance/uninstall-test-framework-to-measure-insert-performance.md delete mode 100644 microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md delete mode 100644 microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.bad.al delete mode 100644 microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.good.al delete mode 100644 microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md delete mode 100644 microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.bad.al delete mode 100644 microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.good.al delete mode 100644 microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.md delete mode 100644 microsoft/knowledge/performance/use-findset-readonly-by-default.bad.al delete mode 100644 microsoft/knowledge/performance/use-findset-readonly-by-default.good.al delete mode 100644 microsoft/knowledge/performance/use-findset-readonly-by-default.md delete mode 100644 microsoft/knowledge/performance/use-findset-with-next.bad.al delete mode 100644 microsoft/knowledge/performance/use-findset-with-next.good.al delete mode 100644 microsoft/knowledge/performance/use-findset-with-next.md rename microsoft/knowledge/performance/{prefer-get-for-primary-key-lookups.bad.al => use-get-instead-of-findfirst-on-full-primary-key.bad.al} (52%) create mode 100644 microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al create mode 100644 microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md delete mode 100644 microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.good.al delete mode 100644 microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md create mode 100644 microsoft/knowledge/performance/use-isempty-for-existence-check.bad.al create mode 100644 microsoft/knowledge/performance/use-isempty-for-existence-check.good.al create mode 100644 microsoft/knowledge/performance/use-isempty-for-existence-check.md delete mode 100644 microsoft/knowledge/performance/use-isempty-for-existence-checks.bad.al delete mode 100644 microsoft/knowledge/performance/use-isempty-for-existence-checks.good.al delete mode 100644 microsoft/knowledge/performance/use-isempty-for-existence-checks.md delete mode 100644 microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.good.al delete mode 100644 microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md delete mode 100644 microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.good.al delete mode 100644 microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md delete mode 100644 microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.bad.al delete mode 100644 microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.good.al delete mode 100644 microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.md create mode 100644 microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md create mode 100644 microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al create mode 100644 microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.good.al create mode 100644 microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md delete mode 100644 microsoft/knowledge/privacy/classify-data-at-migration-destination.bad.al delete mode 100644 microsoft/knowledge/privacy/classify-data-at-migration-destination.good.al delete mode 100644 microsoft/knowledge/privacy/classify-data-at-migration-destination.md create mode 100644 microsoft/knowledge/privacy/data-classification-is-table-field-property.md create mode 100644 microsoft/knowledge/privacy/data-classification-required-on-pii-fields.bad.al create mode 100644 microsoft/knowledge/privacy/data-classification-required-on-pii-fields.good.al create mode 100644 microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md delete mode 100644 microsoft/knowledge/privacy/dataclassification-is-a-table-field-property.md delete mode 100644 microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al delete mode 100644 microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.good.al delete mode 100644 microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.md create mode 100644 microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al create mode 100644 microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md delete mode 100644 microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.bad.al delete mode 100644 microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.good.al delete mode 100644 microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md create mode 100644 microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md create mode 100644 microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.bad.al create mode 100644 microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.good.al create mode 100644 microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md create mode 100644 microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.good.al create mode 100644 microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md delete mode 100644 microsoft/knowledge/privacy/flowfields-auto-inherit-systemmetadata.md create mode 100644 microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al create mode 100644 microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al create mode 100644 microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md delete mode 100644 microsoft/knowledge/privacy/in-memory-data-is-not-a-privacy-concern.md create mode 100644 microsoft/knowledge/privacy/in-memory-data-not-a-privacy-concern.md delete mode 100644 microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.bad.al delete mode 100644 microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.good.al delete mode 100644 microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.md create mode 100644 microsoft/knowledge/privacy/migration-destination-classification.md create mode 100644 microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al create mode 100644 microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al create mode 100644 microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md delete mode 100644 microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.bad.al delete mode 100644 microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.good.al delete mode 100644 microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.md create mode 100644 microsoft/knowledge/privacy/page-display-is-not-a-privacy-concern.md delete mode 100644 microsoft/knowledge/privacy/pages-displaying-permitted-data-is-not-a-privacy-concern.md create mode 100644 microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.bad.al create mode 100644 microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.good.al create mode 100644 microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md create mode 100644 microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al create mode 100644 microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md delete mode 100644 microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.bad.al delete mode 100644 microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.good.al delete mode 100644 microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.md delete mode 100644 microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.bad.al delete mode 100644 microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.good.al delete mode 100644 microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.md create mode 100644 microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al create mode 100644 microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al create mode 100644 microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md delete mode 100644 microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.bad.al delete mode 100644 microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.good.al delete mode 100644 microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md delete mode 100644 microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.bad.al delete mode 100644 microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.good.al delete mode 100644 microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md create mode 100644 microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al create mode 100644 microsoft/knowledge/privacy/table-level-data-classification-cascades.md create mode 100644 microsoft/knowledge/security/al-has-no-built-in-htmlencode.bad.al create mode 100644 microsoft/knowledge/security/al-has-no-built-in-htmlencode.good.al create mode 100644 microsoft/knowledge/security/al-has-no-built-in-htmlencode.md delete mode 100644 microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.bad.al delete mode 100644 microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.good.al delete mode 100644 microsoft/knowledge/security/compose-secrets-with-secretstrsubstno.md delete mode 100644 microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.bad.al delete mode 100644 microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.good.al delete mode 100644 microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.md delete mode 100644 microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.bad.al delete mode 100644 microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.good.al delete mode 100644 microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md delete mode 100644 microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.bad.al delete mode 100644 microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.good.al delete mode 100644 microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.md delete mode 100644 microsoft/knowledge/security/follow-least-privilege-in-permission-sets.bad.al delete mode 100644 microsoft/knowledge/security/follow-least-privilege-in-permission-sets.good.al delete mode 100644 microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md create mode 100644 microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.bad.al create mode 100644 microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md create mode 100644 microsoft/knowledge/security/indirect-permissions-for-elevated-access.bad.al create mode 100644 microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al create mode 100644 microsoft/knowledge/security/indirect-permissions-for-elevated-access.md create mode 100644 microsoft/knowledge/security/inherent-permissions-minimal-grant.bad.al create mode 100644 microsoft/knowledge/security/inherent-permissions-minimal-grant.good.al create mode 100644 microsoft/knowledge/security/inherent-permissions-minimal-grant.md create mode 100644 microsoft/knowledge/security/integrationevent-must-not-expose-secrets.bad.al create mode 100644 microsoft/knowledge/security/integrationevent-must-not-expose-secrets.good.al create mode 100644 microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md create mode 100644 microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.bad.al create mode 100644 microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.good.al create mode 100644 microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md create mode 100644 microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.bad.al create mode 100644 microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.good.al create mode 100644 microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md create mode 100644 microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al create mode 100644 microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al create mode 100644 microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md create mode 100644 microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.bad.al create mode 100644 microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al create mode 100644 microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md delete mode 100644 microsoft/knowledge/security/keep-recordref-open-callers-non-public.good.al delete mode 100644 microsoft/knowledge/security/keep-recordref-open-callers-non-public.md delete mode 100644 microsoft/knowledge/security/never-hardcode-secrets-in-al.bad.al delete mode 100644 microsoft/knowledge/security/never-hardcode-secrets-in-al.good.al delete mode 100644 microsoft/knowledge/security/never-hardcode-secrets-in-al.md create mode 100644 microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al create mode 100644 microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al create mode 100644 microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md create mode 100644 microsoft/knowledge/security/permission-set-avoid-wildcard-grants.bad.al create mode 100644 microsoft/knowledge/security/permission-set-avoid-wildcard-grants.good.al create mode 100644 microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md delete mode 100644 microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md rename microsoft/knowledge/security/{keep-recordref-open-callers-non-public.bad.al => recordref-open-with-caller-table-must-not-be-public.bad.al} (83%) create mode 100644 microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.good.al create mode 100644 microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md create mode 100644 microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al create mode 100644 microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al create mode 100644 microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md create mode 100644 microsoft/knowledge/security/secrettext-for-credentials.bad.al create mode 100644 microsoft/knowledge/security/secrettext-for-credentials.good.al create mode 100644 microsoft/knowledge/security/secrettext-for-credentials.md create mode 100644 microsoft/knowledge/security/secrettext-with-httpclient.bad.al create mode 100644 microsoft/knowledge/security/secrettext-with-httpclient.good.al create mode 100644 microsoft/knowledge/security/secrettext-with-httpclient.md delete mode 100644 microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.bad.al delete mode 100644 microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.good.al delete mode 100644 microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md delete mode 100644 microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.bad.al delete mode 100644 microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.good.al delete mode 100644 microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md delete mode 100644 microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al delete mode 100644 microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al delete mode 100644 microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md delete mode 100644 microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al delete mode 100644 microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al delete mode 100644 microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md delete mode 100644 microsoft/knowledge/security/use-secrettext-for-credentials.bad.al delete mode 100644 microsoft/knowledge/security/use-secrettext-for-credentials.good.al delete mode 100644 microsoft/knowledge/security/use-secrettext-for-credentials.md delete mode 100644 microsoft/knowledge/security/use-secrettext-with-httpclient.bad.al delete mode 100644 microsoft/knowledge/security/use-secrettext-with-httpclient.good.al delete mode 100644 microsoft/knowledge/security/use-secrettext-with-httpclient.md delete mode 100644 microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.bad.al delete mode 100644 microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.good.al delete mode 100644 microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.md create mode 100644 microsoft/knowledge/security/validate-user-configurable-urls.bad.al create mode 100644 microsoft/knowledge/security/validate-user-configurable-urls.good.al create mode 100644 microsoft/knowledge/security/validate-user-configurable-urls.md create mode 100644 microsoft/knowledge/security/validatetablerelation-false-on-user-input.bad.al create mode 100644 microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al create mode 100644 microsoft/knowledge/security/validatetablerelation-false-on-user-input.md create mode 100644 microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.bad.al create mode 100644 microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.good.al create mode 100644 microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md create mode 100644 microsoft/knowledge/style/api-page-camelcase-properties.bad.al rename microsoft/knowledge/style/{follow-api-page-naming-rules.good.al => api-page-camelcase-properties.good.al} (61%) create mode 100644 microsoft/knowledge/style/api-page-camelcase-properties.md create mode 100644 microsoft/knowledge/style/api-page-delayedinsert-true.bad.al create mode 100644 microsoft/knowledge/style/api-page-delayedinsert-true.good.al create mode 100644 microsoft/knowledge/style/api-page-delayedinsert-true.md create mode 100644 microsoft/knowledge/style/api-page-entity-naming-singular-plural.bad.al create mode 100644 microsoft/knowledge/style/api-page-entity-naming-singular-plural.good.al create mode 100644 microsoft/knowledge/style/api-page-entity-naming-singular-plural.md create mode 100644 microsoft/knowledge/style/api-page-version-format.bad.al create mode 100644 microsoft/knowledge/style/api-page-version-format.good.al create mode 100644 microsoft/knowledge/style/api-page-version-format.md delete mode 100644 microsoft/knowledge/style/apply-approved-label-suffixes.bad.al delete mode 100644 microsoft/knowledge/style/apply-approved-label-suffixes.good.al delete mode 100644 microsoft/knowledge/style/apply-approved-label-suffixes.md create mode 100644 microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al create mode 100644 microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al create mode 100644 microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md create mode 100644 microsoft/knowledge/style/block-keywords-start-new-line.bad.al create mode 100644 microsoft/knowledge/style/block-keywords-start-new-line.good.al create mode 100644 microsoft/knowledge/style/block-keywords-start-new-line.md create mode 100644 microsoft/knowledge/style/caption-required-on-page-fields.bad.al create mode 100644 microsoft/knowledge/style/caption-required-on-page-fields.good.al create mode 100644 microsoft/knowledge/style/caption-required-on-page-fields.md create mode 100644 microsoft/knowledge/style/case-action-on-line-after-possibility.bad.al create mode 100644 microsoft/knowledge/style/case-action-on-line-after-possibility.good.al create mode 100644 microsoft/knowledge/style/case-action-on-line-after-possibility.md create mode 100644 microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.bad.al create mode 100644 microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.good.al create mode 100644 microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md create mode 100644 microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md create mode 100644 microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.bad.al create mode 100644 microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.good.al create mode 100644 microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md create mode 100644 microsoft/knowledge/style/file-name-object-type-pattern.md delete mode 100644 microsoft/knowledge/style/follow-api-page-naming-rules.bad.al delete mode 100644 microsoft/knowledge/style/follow-api-page-naming-rules.md create mode 100644 microsoft/knowledge/style/function-call-parentheses-required.bad.al create mode 100644 microsoft/knowledge/style/function-call-parentheses-required.good.al create mode 100644 microsoft/knowledge/style/function-call-parentheses-required.md delete mode 100644 microsoft/knowledge/style/include-comment-on-labels-with-placeholders.bad.al delete mode 100644 microsoft/knowledge/style/include-comment-on-labels-with-placeholders.good.al delete mode 100644 microsoft/knowledge/style/include-comment-on-labels-with-placeholders.md create mode 100644 microsoft/knowledge/style/label-comment-explains-placeholders.bad.al create mode 100644 microsoft/knowledge/style/label-comment-explains-placeholders.good.al create mode 100644 microsoft/knowledge/style/label-comment-explains-placeholders.md create mode 100644 microsoft/knowledge/style/label-locked-for-non-translatable.bad.al create mode 100644 microsoft/knowledge/style/label-locked-for-non-translatable.good.al create mode 100644 microsoft/knowledge/style/label-locked-for-non-translatable.md create mode 100644 microsoft/knowledge/style/label-suffix-approved-list.bad.al create mode 100644 microsoft/knowledge/style/label-suffix-approved-list.good.al create mode 100644 microsoft/knowledge/style/label-suffix-approved-list.md create mode 100644 microsoft/knowledge/style/lowercase-reserved-keywords.bad.al create mode 100644 microsoft/knowledge/style/lowercase-reserved-keywords.good.al create mode 100644 microsoft/knowledge/style/lowercase-reserved-keywords.md delete mode 100644 microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.bad.al delete mode 100644 microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.md delete mode 100644 microsoft/knowledge/style/name-files-as-object-dot-type-dot-al.md create mode 100644 microsoft/knowledge/style/named-invocations-not-object-ids.bad.al create mode 100644 microsoft/knowledge/style/named-invocations-not-object-ids.good.al create mode 100644 microsoft/knowledge/style/named-invocations-not-object-ids.md create mode 100644 microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al create mode 100644 microsoft/knowledge/style/no-begin-end-around-single-statement.good.al create mode 100644 microsoft/knowledge/style/no-begin-end-around-single-statement.md create mode 100644 microsoft/knowledge/style/no-else-after-terminating-statement.bad.al create mode 100644 microsoft/knowledge/style/no-else-after-terminating-statement.good.al create mode 100644 microsoft/knowledge/style/no-else-after-terminating-statement.md create mode 100644 microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al create mode 100644 microsoft/knowledge/style/no-space-before-method-parenthesis.good.al create mode 100644 microsoft/knowledge/style/no-space-before-method-parenthesis.md create mode 100644 microsoft/knowledge/style/object-name-30-char-limit.md create mode 100644 microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al rename microsoft/knowledge/style/{match-optioncaption-count-to-optionmembers.good.al => optioncaption-required-and-matches-membercount.good.al} (55%) create mode 100644 microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md create mode 100644 microsoft/knowledge/style/page-name-must-match-source-table.md delete mode 100644 microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.bad.al delete mode 100644 microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.good.al delete mode 100644 microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.md delete mode 100644 microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.bad.al delete mode 100644 microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.good.al delete mode 100644 microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.md delete mode 100644 microsoft/knowledge/style/require-parentheses-on-function-calls.bad.al delete mode 100644 microsoft/knowledge/style/require-parentheses-on-function-calls.good.al delete mode 100644 microsoft/knowledge/style/require-parentheses-on-function-calls.md create mode 100644 microsoft/knowledge/style/single-space-after-not-operator.bad.al create mode 100644 microsoft/knowledge/style/single-space-after-not-operator.good.al create mode 100644 microsoft/knowledge/style/single-space-after-not-operator.md create mode 100644 microsoft/knowledge/style/single-space-around-binary-operators.bad.al create mode 100644 microsoft/knowledge/style/single-space-around-binary-operators.good.al create mode 100644 microsoft/knowledge/style/single-space-around-binary-operators.md create mode 100644 microsoft/knowledge/style/temporary-variable-temp-prefix.bad.al create mode 100644 microsoft/knowledge/style/temporary-variable-temp-prefix.good.al create mode 100644 microsoft/knowledge/style/temporary-variable-temp-prefix.md create mode 100644 microsoft/knowledge/style/this-keyword-in-codeunits.bad.al create mode 100644 microsoft/knowledge/style/this-keyword-in-codeunits.good.al create mode 100644 microsoft/knowledge/style/this-keyword-in-codeunits.md rename microsoft/knowledge/{ui/field-tooltips-start-with-specifies-and-end-with-period.good.al => style/tooltip-required-on-page-fields.bad.al} (53%) rename microsoft/knowledge/{ui/field-tooltips-start-with-specifies-and-end-with-period.bad.al => style/tooltip-required-on-page-fields.good.al} (51%) create mode 100644 microsoft/knowledge/style/tooltip-required-on-page-fields.md delete mode 100644 microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.bad.al delete mode 100644 microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.good.al delete mode 100644 microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.md delete mode 100644 microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.bad.al delete mode 100644 microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.good.al delete mode 100644 microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.md delete mode 100644 microsoft/knowledge/style/use-this-keyword-in-codeunits.bad.al delete mode 100644 microsoft/knowledge/style/use-this-keyword-in-codeunits.good.al delete mode 100644 microsoft/knowledge/style/use-this-keyword-in-codeunits.md create mode 100644 microsoft/knowledge/style/variable-declaration-order-by-type.bad.al create mode 100644 microsoft/knowledge/style/variable-declaration-order-by-type.good.al create mode 100644 microsoft/knowledge/style/variable-declaration-order-by-type.md create mode 100644 microsoft/knowledge/style/variable-name-must-not-shadow.bad.al create mode 100644 microsoft/knowledge/style/variable-name-must-not-shadow.good.al create mode 100644 microsoft/knowledge/style/variable-name-must-not-shadow.md create mode 100644 microsoft/knowledge/style/xmldoc-for-public-library-procedures.md delete mode 100644 microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.bad.al delete mode 100644 microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.good.al delete mode 100644 microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.md delete mode 100644 microsoft/knowledge/ui/avoid-banned-ui-terms.md delete mode 100644 microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.bad.al delete mode 100644 microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.good.al delete mode 100644 microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md create mode 100644 microsoft/knowledge/ui/control-add-in-accessibility-is-developer-responsibility.md create mode 100644 microsoft/knowledge/ui/control-add-in-has-no-bc-color-tokens.md create mode 100644 microsoft/knowledge/ui/cosmetic-styles-need-no-textual-context.md delete mode 100644 microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md rename microsoft/knowledge/ui/{use-grid-data-table-pattern-consistently.good.al => grid-data-table-heuristic.good.al} (56%) create mode 100644 microsoft/knowledge/ui/grid-data-table-heuristic.md create mode 100644 microsoft/knowledge/ui/group-caption-quality-is-not-an-accessibility-issue.md create mode 100644 microsoft/knowledge/ui/group-labeled-first-child-exception.bad.al rename microsoft/knowledge/ui/{keep-captions-on-editable-fields.good.al => group-labeled-first-child-exception.good.al} (60%) create mode 100644 microsoft/knowledge/ui/group-labeled-first-child-exception.md create mode 100644 microsoft/knowledge/ui/group-show-caption-false-outside-grid-is-not-a-violation.md delete mode 100644 microsoft/knowledge/ui/keep-captions-on-editable-fields.bad.al delete mode 100644 microsoft/knowledge/ui/keep-captions-on-editable-fields.md create mode 100644 microsoft/knowledge/ui/layout-table-with-captions-is-valid.md delete mode 100644 microsoft/knowledge/ui/manually-review-control-addin-ui-accessibility.md create mode 100644 microsoft/knowledge/ui/no-nested-grids.bad.al create mode 100644 microsoft/knowledge/ui/no-nested-grids.md create mode 100644 microsoft/knowledge/ui/on-drill-down-on-non-editable-fields-renders-as-link.md delete mode 100644 microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.bad.al delete mode 100644 microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.good.al delete mode 100644 microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.md delete mode 100644 microsoft/knowledge/ui/respect-ui-text-character-limits.md create mode 100644 microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.good.al create mode 100644 microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md create mode 100644 microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.bad.al create mode 100644 microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.good.al create mode 100644 microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md create mode 100644 microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.good.al create mode 100644 microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md create mode 100644 microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.good.al create mode 100644 microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md create mode 100644 microsoft/knowledge/ui/show-caption-in-repeater-allowed.good.al create mode 100644 microsoft/knowledge/ui/show-caption-in-repeater-allowed.md create mode 100644 microsoft/knowledge/ui/show-caption-on-editable-fields.bad.al create mode 100644 microsoft/knowledge/ui/show-caption-on-editable-fields.good.al create mode 100644 microsoft/knowledge/ui/show-caption-on-editable-fields.md create mode 100644 microsoft/knowledge/ui/standalone-content-in-layout-table.good.al create mode 100644 microsoft/knowledge/ui/standalone-content-in-layout-table.md create mode 100644 microsoft/knowledge/ui/style-expr-text-vs-boolean.good.al create mode 100644 microsoft/knowledge/ui/style-expr-text-vs-boolean.md create mode 100644 microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.bad.al create mode 100644 microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md delete mode 100644 microsoft/knowledge/ui/titles-have-no-trailing-punctuation.md delete mode 100644 microsoft/knowledge/ui/tooltips-describe-teaching-tips-guide.md delete mode 100644 microsoft/knowledge/ui/tour-tips-do-not-use-action-language.md delete mode 100644 microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.bad.al delete mode 100644 microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.good.al delete mode 100644 microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.md delete mode 100644 microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.bad.al delete mode 100644 microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.md delete mode 100644 microsoft/knowledge/upgrade/assess-existing-data-before-key-or-type-changes.md create mode 100644 microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.bad.al create mode 100644 microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.good.al create mode 100644 microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md delete mode 100644 microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.bad.al delete mode 100644 microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.good.al delete mode 100644 microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.md create mode 100644 microsoft/knowledge/upgrade/datatransfer-for-bulk-init.bad.al create mode 100644 microsoft/knowledge/upgrade/datatransfer-for-bulk-init.good.al create mode 100644 microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md create mode 100644 microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al create mode 100644 microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al create mode 100644 microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md delete mode 100644 microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.bad.al delete mode 100644 microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.md create mode 100644 microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.bad.al create mode 100644 microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al create mode 100644 microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md delete mode 100644 microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md delete mode 100644 microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.bad.al delete mode 100644 microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.good.al delete mode 100644 microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md create mode 100644 microsoft/knowledge/upgrade/enum-values-additive-at-end.bad.al create mode 100644 microsoft/knowledge/upgrade/enum-values-additive-at-end.good.al create mode 100644 microsoft/knowledge/upgrade/enum-values-additive-at-end.md delete mode 100644 microsoft/knowledge/upgrade/exclude-hybrid-migration-codeunits-from-standard-upgrade-rules.md create mode 100644 microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al rename microsoft/knowledge/upgrade/{detect-first-install-via-dataversion-zero.good.al => first-install-dataversion-zero-check.good.al} (55%) create mode 100644 microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md create mode 100644 microsoft/knowledge/upgrade/guard-database-reads.bad.al create mode 100644 microsoft/knowledge/upgrade/guard-database-reads.good.al create mode 100644 microsoft/knowledge/upgrade/guard-database-reads.md delete mode 100644 microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.bad.al delete mode 100644 microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.good.al delete mode 100644 microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.md delete mode 100644 microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.bad.al delete mode 100644 microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.good.al delete mode 100644 microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.md create mode 100644 microsoft/knowledge/upgrade/hybrid-migration-codeunits-not-standard-upgrade.md delete mode 100644 microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.bad.al delete mode 100644 microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.good.al delete mode 100644 microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md create mode 100644 microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.bad.al create mode 100644 microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.good.al create mode 100644 microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md create mode 100644 microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al create mode 100644 microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al create mode 100644 microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md create mode 100644 microsoft/knowledge/upgrade/no-external-calls-in-upgrade.bad.al create mode 100644 microsoft/knowledge/upgrade/no-external-calls-in-upgrade.good.al create mode 100644 microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md create mode 100644 microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.bad.al create mode 100644 microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.good.al create mode 100644 microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md create mode 100644 microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al create mode 100644 microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al create mode 100644 microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md delete mode 100644 microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.bad.al delete mode 100644 microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.good.al delete mode 100644 microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.md create mode 100644 microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.bad.al create mode 100644 microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.good.al create mode 100644 microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md create mode 100644 microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.bad.al create mode 100644 microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.good.al create mode 100644 microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md create mode 100644 microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al create mode 100644 microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al create mode 100644 microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md delete mode 100644 microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.bad.al delete mode 100644 microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.good.al delete mode 100644 microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md delete mode 100644 microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.bad.al delete mode 100644 microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.good.al delete mode 100644 microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.md diff --git a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.good.al b/microsoft/knowledge/performance/add-sift-keys-for-flowfields.good.al deleted file mode 100644 index affc10d..0000000 --- a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.good.al +++ /dev/null @@ -1,10 +0,0 @@ -tableextension 50118 "Perf Sample SIFTKey" extends "Cust. Ledger Entry" -{ - keys - { - key(PerfSampleOpenByCustomer; "Customer No.", Open, "Posting Date") - { - SumIndexFields = "Remaining Amt. (LCY)"; - } - } -} diff --git a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md b/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md deleted file mode 100644 index 1a1843f..0000000 --- a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [sift, sumindexfields, flowfield, key, aa0232] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Add SIFT keys for FlowField aggregations - -## Description - -CodeCop rule AA0232 checks that FlowFields backed by CalcSums or aggregation CalcFormula are supported by a key whose SumIndexFields include the summed field and whose key prefix matches the formula's filter fields. Without a SIFT key the platform falls back to a full aggregation on every read — typically invisible in development and catastrophic in production. - -## Best Practice - -For each Sum-style FlowField, ensure the source table has a key whose leading fields match the FlowField's CalcFormula WHERE clause and whose SumIndexFields list includes the summed field. Table extensions adding new FlowFields are responsible for adding the supporting key. - -See sample: `add-sift-keys-for-flowfields.good.al`. - -## Anti Pattern - -Declaring a FlowField on a hot table without checking whether a supporting SIFT key exists ships a latent scan into every list page and report that touches the field. - diff --git a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al new file mode 100644 index 0000000..2fd95ac --- /dev/null +++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al @@ -0,0 +1,14 @@ +report 50221 "Perf Sample AddLoadFields Bad" +{ + dataset + { + // No AddLoadFields: every Cust. Ledger Entry column ships per row, even though + // only three columns feed the layout. + dataitem(CustLedgerEntry; "Cust. Ledger Entry") + { + column(CustomerNo; "Customer No.") { } + column(PostingDate; "Posting Date") { } + column(Amount; Amount) { } + } + } +} diff --git a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.good.al b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al similarity index 76% rename from microsoft/knowledge/performance/use-addloadfields-in-report-layouts.good.al rename to microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al index 278957d..3267418 100644 --- a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.good.al +++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al @@ -1,8 +1,8 @@ -report 50112 "Perf Sample AddLoadFields Good" +report 50220 "Perf Sample AddLoadFields Good" { dataset { - dataitem(Cust; "Cust. Ledger Entry") + dataitem(CustLedgerEntry; "Cust. Ledger Entry") { column(CustomerNo; "Customer No.") { } column(PostingDate; "Posting Date") { } diff --git a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md new file mode 100644 index 0000000..aa811ce --- /dev/null +++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [report, addloadfields, onpredataitem, dataitem, partial-record, layout] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# In reports, declare the fields the layout needs with AddLoadFields + +## Description + +Reports iterate dataitems on potentially large source tables and pipe rows into a layout. The partial-record optimization is the same idea as `use-setloadfields-for-partial-records.md`, but the API is different: per the upstream guidance, "for reports, use `AddLoadFields()` in `OnPreDataItem` trigger to add fields needed by the layout." `AddLoadFields` is additive — call it for each field the layout consumes — and runs once per dataitem before iteration begins. + +## Best Practice + +In each dataitem's `OnPreDataItem` trigger, list the columns the layout binds to via `AddLoadFields(, , ...)`. The platform then materializes only those columns per row. Treat the layout column list as the spec: every column the layout uses must be added; columns the layout does not use should not be added. + +See sample: `addloadfields-in-report-onpredataitem.good.al`. + +## Anti Pattern + +Relying on the dataitem's default to load every field. On a report bound to a ledger-scale table this transfers an entire row per iteration, of which the layout reads a fraction. + +See sample: `addloadfields-in-report-onpredataitem.bad.al`. diff --git a/microsoft/knowledge/performance/admin-and-migration-pages-tolerate-lower-perf.md b/microsoft/knowledge/performance/admin-and-migration-pages-tolerate-lower-perf.md new file mode 100644 index 0000000..3e40ef3 --- /dev/null +++ b/microsoft/knowledge/performance/admin-and-migration-pages-tolerate-lower-perf.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [admin-page, migration, wizard, hybrid, permissions, lower-severity] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Admin and migration pages tolerate lower performance discipline + +## Description + +Some pages run rarely and against small datasets, and the upstream guidance explicitly calls for treating them as lower severity. Per the review checklist, "Admin/migration pages (`Admin`, `Setup`, `Wizard`, `Migration`, `HybridBC14`, `HybridSL`, `HybridGP` namespaces, `Permissions`/`PermissionSet` pages) are infrequently used with small datasets — apply lower severity." The same logic covers one-time wizards and tenant-bootstrap routines: the code path runs a handful of times in the lifetime of a tenant, against a bounded dataset, by an administrator. + +## Best Practice + +When triaging a finding on an admin, migration, or wizard page, downgrade severity relative to the same finding on a hot business path. A `FindSet` loop without `SetLoadFields` on a migration page that processes setup records once per tenant is a different finding than the same loop on a posting routine that runs thousands of times a day. Note this context explicitly in the review so the call site is not "fixed" twice with diminishing returns. + +## Anti Pattern + +Treating a migration wizard's per-row loop with the same urgency as the same loop in `Sales-Post`. The fix cost is the same; the production benefit is not. Bulk-rewriting an admin page to use `ModifyAll` and partial records buys nothing the user will perceive. diff --git a/microsoft/knowledge/performance/filter-before-find.bad.al b/microsoft/knowledge/performance/apply-filters-before-iterating.bad.al similarity index 60% rename from microsoft/knowledge/performance/filter-before-find.bad.al rename to microsoft/knowledge/performance/apply-filters-before-iterating.bad.al index 12d797e..6dc23df 100644 --- a/microsoft/knowledge/performance/filter-before-find.bad.al +++ b/microsoft/knowledge/performance/apply-filters-before-iterating.bad.al @@ -1,7 +1,10 @@ -codeunit 50101 "Perf Sample FilterBeforeFind Bad" +codeunit 50229 "Perf Sample FilterEarly Bad" { - procedure ProcessUsCustomers(var Customer: Record Customer) + procedure ProcessUSCustomers() + var + Customer: Record Customer; begin + // Reads every customer in the table, discards the non-US ones in AL. if Customer.FindSet() then repeat if Customer."Country/Region Code" = 'US' then @@ -11,6 +14,5 @@ codeunit 50101 "Perf Sample FilterBeforeFind Bad" local procedure ProcessCustomer(var Customer: Record Customer) begin - // per-customer work end; } diff --git a/microsoft/knowledge/performance/filter-before-find.good.al b/microsoft/knowledge/performance/apply-filters-before-iterating.good.al similarity index 67% rename from microsoft/knowledge/performance/filter-before-find.good.al rename to microsoft/knowledge/performance/apply-filters-before-iterating.good.al index a8dceda..820b102 100644 --- a/microsoft/knowledge/performance/filter-before-find.good.al +++ b/microsoft/knowledge/performance/apply-filters-before-iterating.good.al @@ -1,6 +1,8 @@ -codeunit 50100 "Perf Sample FilterBeforeFind Good" +codeunit 50228 "Perf Sample FilterEarly Good" { - procedure ProcessUsCustomers(var Customer: Record Customer) + procedure ProcessUSCustomers() + var + Customer: Record Customer; begin Customer.SetRange("Country/Region Code", 'US'); if Customer.FindSet() then @@ -11,6 +13,5 @@ codeunit 50100 "Perf Sample FilterBeforeFind Good" local procedure ProcessCustomer(var Customer: Record Customer) begin - // per-customer work end; } diff --git a/microsoft/knowledge/performance/apply-filters-before-iterating.md b/microsoft/knowledge/performance/apply-filters-before-iterating.md new file mode 100644 index 0000000..5f54c3b --- /dev/null +++ b/microsoft/knowledge/performance/apply-filters-before-iterating.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [setrange, setfilter, filter, loop, early, dataset] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Apply SetRange/SetFilter before iterating, not as an if-test inside the loop + +## Description + +A `SetRange` or `SetFilter` placed before `FindSet` narrows the result set at the database. The same condition expressed as an `if` inside the loop body filters in AL, after every row has crossed the boundary. Per the upstream guidance, "apply `SetRange`/`SetFilter` as early as possible to reduce dataset" and "more specific filters = better performance." On a production-scale table the difference is the difference between scanning a subset and scanning the whole table. + +## Best Practice + +Move every predicate that can be expressed as an equality or range filter into a `SetRange` or `SetFilter` ahead of the find. Combine with `SetCurrentKey` to choose a key whose first fields match the filter (see `setcurrentkey-aligns-key-with-filters.md`). The loop body should then contain only the work that depends on per-row state. + +See sample: `apply-filters-before-iterating.good.al`. + +## Anti Pattern + +`if Customer.FindSet() then repeat if Customer."Country/Region Code" = 'US' then ProcessCustomer(Customer); until Customer.Next() = 0;` — the loop pays for every row in the table and discards the non-matching ones in AL. The intent is the same as a `SetRange("Country/Region Code", 'US')` ahead of the find, but the cost is not. + +See sample: `apply-filters-before-iterating.bad.al`. diff --git a/microsoft/knowledge/performance/apply-guards-before-get.bad.al b/microsoft/knowledge/performance/apply-guards-before-get.bad.al new file mode 100644 index 0000000..2c0c710 --- /dev/null +++ b/microsoft/knowledge/performance/apply-guards-before-get.bad.al @@ -0,0 +1,14 @@ +codeunit 50215 "Perf Sample GuardBeforeGet Bad" +{ + procedure ResolveAllocation(var PurchaseLine: Record "Purchase Line") + var + PurchaseHeader: Record "Purchase Header"; + begin + // Wasted lookup when the line has no allocation account: the procedure + // exits below, but the header was already fetched. + PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); + if PurchaseLine."Selected Alloc. Account No." = '' then + exit; + // ... + end; +} diff --git a/microsoft/knowledge/performance/guard-before-get-not-after.bad.al b/microsoft/knowledge/performance/apply-guards-before-get.good.al similarity index 50% rename from microsoft/knowledge/performance/guard-before-get-not-after.bad.al rename to microsoft/knowledge/performance/apply-guards-before-get.good.al index da0d391..52141f7 100644 --- a/microsoft/knowledge/performance/guard-before-get-not-after.bad.al +++ b/microsoft/knowledge/performance/apply-guards-before-get.good.al @@ -1,15 +1,12 @@ -codeunit 51201 "Perf Sample GuardBeforeGet Bad" +codeunit 50214 "Perf Sample GuardBeforeGet Good" { - procedure HandleLine(var PurchaseLine: Record "Purchase Line") + procedure ResolveAllocation(var PurchaseLine: Record "Purchase Line") var PurchaseHeader: Record "Purchase Header"; begin - // Get fires on every call — including the ones that exit immediately below. - PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); - if PurchaseLine."Selected Alloc. Account No." = '' then exit; - - // Work with PurchaseHeader. + PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); + // ... end; } diff --git a/microsoft/knowledge/performance/apply-guards-before-get.md b/microsoft/knowledge/performance/apply-guards-before-get.md new file mode 100644 index 0000000..9e77411 --- /dev/null +++ b/microsoft/knowledge/performance/apply-guards-before-get.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [get, guard, early-exit, conditional, lookup, wasted-query] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Apply early-exit guards before calling Get + +## Description + +A `Get` (or any other database call) executed before a guard that may exit the procedure does a round-trip the procedure never uses. Per the upstream guidance, "Flag `Get()` calls that execute before a guard condition that may exit early — the DB lookup is wasted." The fix is structural: order the procedure body so cheap checks (parameter validation, in-memory field comparisons, enum tests) run first, and the database call runs only after the guards pass. + +## Best Practice + +Read the procedure top-to-bottom and place every condition that can short-circuit ahead of every database call. The check `if SomeNo = '' then exit;` belongs above `Header.Get(...)`, not below. Each guard moved upward saves one wasted query on the path that exits. + +See sample: `apply-guards-before-get.good.al`. + +## Anti Pattern + +`Record.Get(...)` at the top of a procedure followed by `if SomeField = '' then exit;`. The code reads top-down as "load the record, then decide whether we needed it" — exactly the order that wastes the query. The pattern is easy to introduce when guards are added later, defensively, without re-checking call ordering. + +See sample: `apply-guards-before-get.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-calcfields-in-loops.bad.al b/microsoft/knowledge/performance/avoid-calcfields-in-loops.bad.al deleted file mode 100644 index bc98ef1..0000000 --- a/microsoft/knowledge/performance/avoid-calcfields-in-loops.bad.al +++ /dev/null @@ -1,18 +0,0 @@ -codeunit 50117 "Perf Sample CalcFieldsInLoop Bad" -{ - procedure ProcessLargeLines(var SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line") - begin - SalesLine.SetRange("Document Type", SalesHeader."Document Type"); - SalesLine.SetRange("Document No.", SalesHeader."No."); - if SalesLine.FindSet() then - repeat - SalesHeader.CalcFields(Amount); - if SalesHeader.Amount > 1000 then - ProcessLine(SalesLine); - until SalesLine.Next() = 0; - end; - - local procedure ProcessLine(var SalesLine: Record "Sales Line") - begin - end; -} diff --git a/microsoft/knowledge/performance/avoid-calcfields-in-loops.good.al b/microsoft/knowledge/performance/avoid-calcfields-in-loops.good.al deleted file mode 100644 index c02ca11..0000000 --- a/microsoft/knowledge/performance/avoid-calcfields-in-loops.good.al +++ /dev/null @@ -1,18 +0,0 @@ -codeunit 50116 "Perf Sample CalcFieldsInLoop Good" -{ - procedure ProcessLargeLines(var SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line") - begin - SalesHeader.CalcFields(Amount); - SalesLine.SetRange("Document Type", SalesHeader."Document Type"); - SalesLine.SetRange("Document No.", SalesHeader."No."); - if SalesLine.FindSet() then - repeat - if SalesHeader.Amount > 1000 then - ProcessLine(SalesLine); - until SalesLine.Next() = 0; - end; - - local procedure ProcessLine(var SalesLine: Record "Sales Line") - begin - end; -} diff --git a/microsoft/knowledge/performance/avoid-calcfields-in-loops.md b/microsoft/knowledge/performance/avoid-calcfields-in-loops.md deleted file mode 100644 index 67fb793..0000000 --- a/microsoft/knowledge/performance/avoid-calcfields-in-loops.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [calcfields, flowfield, loop, n-plus-one] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not call CalcFields inside loops - -## Description - -CalcFields evaluates one or more FlowFields for the current record by issuing a separate SQL aggregation. Called inside a loop over a record set, it becomes an N+1 problem: one aggregate per row. For any non-trivial set on a ledger-entry-backed FlowField this is orders of magnitude slower than the equivalent batched query. - -## Best Practice - -Move CalcFields out of the iteration. If the total is what you need, use CalcSums on the filtered parent set. If row-by-row FlowField values are needed, reshape the computation so the aggregate runs once — for example by joining against a temporary table populated in a single batched query. - -**Acceptable exceptions:** CalcFields inside an `OnAfterGetRecord` page trigger is the standard pattern for displaying computed FlowField values — the platform calls this trigger once per row and it is not a developer-authored loop. Similarly, CalcFields inside an `OnValidate` field trigger fires at most once per user action and is acceptable. The concern is only developer-written `FindSet … repeat … until Next() = 0` loops. - -See sample: `avoid-calcfields-in-loops.good.al`. - -## Anti Pattern - -Calling CalcFields inside `repeat ... until Next() = 0` on a hot parent record is the textbook N+1 pattern. Even a modest parent set size (hundreds of rows) turns into thousands of round-trips. - -See sample: `avoid-calcfields-in-loops.bad.al`. - diff --git a/microsoft/knowledge/performance/avoid-findfirst-with-next.bad.al b/microsoft/knowledge/performance/avoid-findfirst-with-next.bad.al deleted file mode 100644 index 027ada6..0000000 --- a/microsoft/knowledge/performance/avoid-findfirst-with-next.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50105 "Perf Sample AvoidFindFirstNext Bad" -{ - procedure EmitAllItems(var Item: Record Item) - begin - if Item.FindFirst() then - repeat - EmitItem(Item); - until Item.Next() = 0; - end; - - local procedure EmitItem(var Item: Record Item) - begin - end; -} diff --git a/microsoft/knowledge/performance/avoid-findfirst-with-next.md b/microsoft/knowledge/performance/avoid-findfirst-with-next.md deleted file mode 100644 index 267aaac..0000000 --- a/microsoft/knowledge/performance/avoid-findfirst-with-next.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [findfirst, findlast, get, next, aa0233] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not pair FindFirst, FindLast, or Get with Next - -## Description - -CodeCop rule AA0233 flags loops that start with FindFirst, FindLast, or Get and then call Next. FindFirst and FindLast retrieve a single row and reposition the cursor; calling Next after them forces the platform to re-seek and stream the rest of the set, which is slower than the correct FindSet pattern and signals intent incorrectly to reviewers and the optimizer. - -## Best Practice - -Choose the Find variant that matches the operation: FindSet for full iteration, FindFirst or FindLast when you want exactly one row, Get when the primary key is known. Never call Next after FindFirst, FindLast, or Get. - -## Anti Pattern - -Writing `if Rec.FindFirst() then repeat ... until Rec.Next() = 0` is the canonical AA0233 offender. The loop wastes bandwidth and obscures the author's intent. - -See sample: `avoid-findfirst-with-next.bad.al`. - diff --git a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.bad.al b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.bad.al new file mode 100644 index 0000000..7ff67be --- /dev/null +++ b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.bad.al @@ -0,0 +1,15 @@ +codeunit 50253 "Perf Sample NPlus1 Bad" +{ + procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal + var + Item: Record Item; + begin + if BOMLine.FindSet() then + repeat + // Full-row Item.Get per BOM line — no partial loading, no caching. + Item.Get(BOMLine."No."); + if Item."Costing Method" = Item."Costing Method"::Standard then + TotalCost += Item."Standard Cost" * BOMLine."Quantity per"; + until BOMLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al new file mode 100644 index 0000000..2bdbf65 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al @@ -0,0 +1,15 @@ +codeunit 50252 "Perf Sample NPlus1 Good" +{ + procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal + var + Item: Record Item; + begin + Item.SetLoadFields("Costing Method", "Standard Cost"); + if BOMLine.FindSet() then + repeat + if Item.Get(BOMLine."No.") then + if Item."Costing Method" = Item."Costing Method"::Standard then + TotalCost += Item."Standard Cost" * BOMLine."Quantity per"; + until BOMLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md new file mode 100644 index 0000000..2908d3f --- /dev/null +++ b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [n-plus-one, get, findfirst, loop, inner-lookup, large-table] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid Get / FindFirst inside a loop on a large inner table + +## Description + +A `Get` or `FindFirst` against a different record inside a loop body produces one database round-trip per iteration — the classic N+1 pattern. Per the upstream guidance, "Flag when a `Get()`/`FindFirst()` is called inside a loop for each record — this creates N+1 database round-trips." The cost only matters when the inner table is meaningful: lookups against temporary tables, singleton setup tables, enum-mapping tables, permission objects, or Role IDs are bounded and safe. The pattern to catch is the inner lookup that hits a production-scale table for every outer row. + +## Best Practice + +When the loop needs values from another record, lift the lookup out of the loop if the rows can be collected up front, or apply `SetLoadFields` so each inner read transfers only the columns the loop actually uses (see `use-setloadfields-for-partial-records.md`). When the inner record is small or bounded, leave the call site alone — the rule targets large-table inner lookups specifically. + +See sample: `avoid-get-inside-loop-on-large-table.good.al`. + +## Anti Pattern + +Iterating BOM lines and calling `Item.Get(BOMLine."No.")` per row to read a costing method, with no `SetLoadFields` on `Item`. Each iteration issues one query against Item (~800k rows) and pulls the entire row to read two fields. The fix is `Item.SetLoadFields("Costing Method", "Standard Cost");` ahead of the loop — still N reads, but each one transfers only the needed columns. + +See sample: `avoid-get-inside-loop-on-large-table.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.bad.al b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.bad.al new file mode 100644 index 0000000..f0cee4f --- /dev/null +++ b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.bad.al @@ -0,0 +1,22 @@ +codeunit 50255 "Perf Sample RecRef Bad" +{ + procedure ProcessAllCustomerNames() + var + Customer: Record Customer; + RecRef: RecordRef; + FldRef: FieldRef; + begin + RecRef.Open(Database::Customer); + if RecRef.FindSet() then + repeat + // Table and field are fixed at compile time, but every iteration + // pays dynamic resolution cost. + FldRef := RecRef.Field(Customer.FieldNo(Name)); + ProcessName(Format(FldRef.Value)); + until RecRef.Next() = 0; + end; + + local procedure ProcessName(Name: Text) + begin + end; +} diff --git a/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.good.al b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.good.al new file mode 100644 index 0000000..86dcc3d --- /dev/null +++ b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.good.al @@ -0,0 +1,16 @@ +codeunit 50254 "Perf Sample RecRef Good" +{ + procedure ProcessAllCustomerNames() + var + Customer: Record Customer; + begin + if Customer.FindSet() then + repeat + ProcessName(Customer.Name); + until Customer.Next() = 0; + end; + + local procedure ProcessName(Name: Text[100]) + begin + end; +} diff --git a/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md new file mode 100644 index 0000000..b718449 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [recordref, fieldref, hot-loop, typed-record, metadata] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid RecordRef / FieldRef in hot loops when a typed record fits + +## Description + +`RecordRef` and `FieldRef` are slower than direct typed record access — the platform resolves the table and field at runtime instead of at compile time. The trade-off is intentional: per the upstream guidance, "RecordRef/FieldRef operations are slower than direct record access, but many features REQUIRE them for generic metadata iteration (permission checks, field copying, dynamic field access)." The rule, then, is not "never use them" but "only flag when used inside a clearly unbounded hot loop (10k+ iterations) where a typed alternative exists." + +## Best Practice + +Use `RecordRef`/`FieldRef` for genuinely generic code — permission checks, field copying, table-agnostic export. When the loop target is known at compile time and the loop iterates a large number of rows, declare the typed record and access fields directly; the saved per-iteration overhead is measurable at the volumes the rule targets. + +See sample: `avoid-recordref-in-hot-loop.good.al`. + +## Anti Pattern + +`RecRef.Open(Database::Customer); if RecRef.FindSet() then repeat FldRef := RecRef.Field(Customer.FieldNo(Name)); ProcessName(FldRef.Value); until RecRef.Next() = 0;` — the table is fixed at compile time, the field is fixed at compile time, and the loop pays the dynamic-resolution cost on every iteration. The direct `Customer.Name` form does the same work without the lookup. + +See sample: `avoid-recordref-in-hot-loop.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.bad.al b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.bad.al new file mode 100644 index 0000000..e7358fb --- /dev/null +++ b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.bad.al @@ -0,0 +1,21 @@ +page 50217 "Perf Sample Redundant Bad" +{ + PageType = ListPart; + SourceTable = "Assembly Line"; + + var + AssemblyLineRec: Record "Assembly Line"; + ShowWarning: Boolean; + + trigger OnAfterGetRecord() + begin + // Redundant: the platform already fetched the row into Rec. + AssemblyLineRec.Get("Document Type", "Document No.", "Line No."); + ShowWarning := CheckAvailability(AssemblyLineRec); + end; + + local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean + begin + exit(AssemblyLine.Quantity > 0); + end; +} diff --git a/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.good.al b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.good.al new file mode 100644 index 0000000..6cb60ea --- /dev/null +++ b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.good.al @@ -0,0 +1,18 @@ +page 50216 "Perf Sample Redundant Good" +{ + PageType = ListPart; + SourceTable = "Assembly Line"; + + var + ShowWarning: Boolean; + + trigger OnAfterGetRecord() + begin + ShowWarning := CheckAvailability(Rec); + end; + + local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean + begin + exit(AssemblyLine.Quantity > 0); + end; +} diff --git a/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md new file mode 100644 index 0000000..9684769 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [get, onaftergetrecord, redundant, page-trigger, rec, already-loaded] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not Get the record the page already loaded + +## Description + +A list or card page's `OnAfterGetRecord` trigger fires *because* the platform has already fetched a row into `Rec`. Calling `Get` for that same row inside the trigger repeats the read the platform just did. Per the upstream guidance, this is "redundant — record already fetched by page runtime"; the correction is "use `Rec` directly — already loaded." The waste compounds on list pages, where the trigger runs once per row displayed. + +## Best Practice + +Inside page triggers — `OnAfterGetRecord`, `OnAfterGetCurrRecord`, validation triggers — read from `Rec` (or the trigger's record parameter). The platform exposes the freshly loaded record there for exactly this purpose. Reach for `Get` only when the trigger needs a *different* record than the one being displayed. + +See sample: `avoid-redundant-get-when-record-already-loaded.good.al`. + +## Anti Pattern + +`AssemblyLineRec.Get("Document Type", "Document No.", "Line No.");` at the top of `OnAfterGetRecord`, when the trigger is on the `Assembly Line` page itself and `Rec` already holds that row. The pattern often appears when a helper that expects a record parameter is invoked from a page trigger and the author writes a `Get` to "freshen" `Rec` rather than passing `Rec` through. + +See sample: `avoid-redundant-get-when-record-already-loaded.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.bad.al b/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.bad.al deleted file mode 100644 index 035b26b..0000000 --- a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50127 "Perf Sample UserInTxn Bad" -{ - procedure ArchiveSalesHeader(var SalesHeader: Record "Sales Header") - begin - SalesHeader.Status := SalesHeader.Status::Released; - SalesHeader.Modify(); - if not Confirm('Archive document %1?', false, SalesHeader."No.") then - exit; - SalesHeader.Delete(true); - end; -} diff --git a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.good.al b/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.good.al deleted file mode 100644 index f59e635..0000000 --- a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50126 "Perf Sample UserInTxn Good" -{ - procedure ArchiveSalesHeader(var SalesHeader: Record "Sales Header") - begin - if not Confirm('Archive document %1?', false, SalesHeader."No.") then - exit; - DoArchive(SalesHeader); - end; - - local procedure DoArchive(var SalesHeader: Record "Sales Header") - begin - // only Insert/Modify/Delete calls happen here; no prompts - end; -} diff --git a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md b/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md deleted file mode 100644 index 548ee24..0000000 --- a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [confirm, strmenu, message, transaction, dialog] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not prompt the user inside a write transaction - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Confirm, StrMenu, Message, and any other user-facing dialog pauses execution while the transaction is still open. During that pause every lock held by the transaction blocks other sessions. A user who walks away from the screen can suspend business-critical tables for an unbounded period. - -## Best Practice - -Gather every user decision before the writing phase begins. Once the decisions are known, run the transaction end-to-end without prompts. - -See sample: `avoid-user-interaction-in-transactions.good.al`. - -## Anti Pattern - -Calling Confirm or StrMenu from inside an OnInsert, OnModify, or OnDelete trigger — or from any code path that has already started modifying records — blocks on user input while holding locks. - -See sample: `avoid-user-interaction-in-transactions.bad.al`. - diff --git a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.bad.al b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.bad.al new file mode 100644 index 0000000..ce5cf31 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.bad.al @@ -0,0 +1,18 @@ +codeunit 50239 "Perf Sample PromptInTxn Bad" +{ + procedure PostOrder(DocNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + PostConfirmQst: Label 'Post this order?'; + begin + SalesHeader.LockTable(); + SalesHeader.Get(SalesHeader."Document Type"::Order, DocNo); + // Lock held while the dialog is on screen — minutes or hours. + if Confirm(PostConfirmQst) then + PostSalesOrder(SalesHeader); + end; + + local procedure PostSalesOrder(var SalesHeader: Record "Sales Header") + begin + end; +} diff --git a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.good.al b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.good.al new file mode 100644 index 0000000..1407d3c --- /dev/null +++ b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.good.al @@ -0,0 +1,18 @@ +codeunit 50238 "Perf Sample PromptInTxn Good" +{ + procedure PostOrder(DocNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + PostConfirmQst: Label 'Post this order?'; + begin + if not Confirm(PostConfirmQst) then + exit; + SalesHeader.LockTable(); + SalesHeader.Get(SalesHeader."Document Type"::Order, DocNo); + PostSalesOrder(SalesHeader); + end; + + local procedure PostSalesOrder(var SalesHeader: Record "Sales Header") + begin + end; +} diff --git a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md new file mode 100644 index 0000000..834641a --- /dev/null +++ b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [confirm, strmenu, dialog, transaction, lock, user-interaction] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not hold locks while waiting for the user + +## Description + +A `Confirm`, `StrMenu`, modal page, or other user prompt issued from inside a write transaction stalls the transaction — and therefore every lock it holds — until the user responds. Per the upstream guidance, "Avoid user interactions (Confirm, StrMenu) inside transactions — they hold locks while waiting for user input." The wait is bounded only by the user; meanwhile other sessions block on whatever this transaction has acquired. + +## Best Practice + +Sequence the operation so user confirmation happens *before* any database write that takes a lock the prompt holds open. The shape is: ask the user → if confirmed, acquire locks and post. `if Confirm(...) then begin SalesHeader.LockTable(); SalesHeader.Get(DocNo); PostSalesOrder(SalesHeader); end;` keeps the lock window down to the work itself. + +See sample: `avoid-user-prompts-inside-transactions.good.al`. + +## Anti Pattern + +`SalesHeader.LockTable(); SalesHeader.Get(DocNo); if Confirm('Post this order?') then ...;` — the lock is held for as long as the dialog is up. A user who steps away to lunch holds the lock for an hour, and every other session that touches that row blocks for the duration. + +See sample: `avoid-user-prompts-inside-transactions.bad.al`. diff --git a/microsoft/knowledge/performance/blob-fields-are-not-cached-prefer-media.md b/microsoft/knowledge/performance/blob-fields-are-not-cached-prefer-media.md deleted file mode 100644 index 75b4347..0000000 --- a/microsoft/knowledge/performance/blob-fields-are-not-cached-prefer-media.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [blob, media, mediaset, cache, image, thumbnail] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Blob fields are never cached — prefer Media or MediaSet for images - -## Description - -`Blob` field contents are not cached by the Business Central server or the client. Every read re-fetches the full payload from the database, even when the same blob was read moments earlier in the same session. For images displayed on a page, this turns into a database round-trip per render. - -`Media` and `MediaSet` are purpose-built for this and behave differently in two ways that matter for performance. First, they are cached on the client, so subsequent renders of the same image do not re-hit the database. Second, the platform generates a thumbnail when the data is saved, so a list or card page can show the thumbnail immediately and lazy-load the full-resolution image — typically via a Page Background Task — only when needed. - -`Blob` remains appropriate for non-image binary data that is written once and rarely read, or for data the platform does not need to render. For any field that is displayed repeatedly — profile pictures, item images, logos on documents — `Media` or `MediaSet` is the default. - -## Best Practice - -Store images in `Media` or `MediaSet` fields. Bind the thumbnail to the page; load full-resolution data asynchronously when the user opens the full view. Reserve `Blob` for opaque payloads that are not rendered in the UI. - -## Anti Pattern - -An Item Image field defined as `Blob` and shown directly on a list page. Every scroll re-fetches every image from SQL, the list page load time scales with row count and image size, and no client-side caching mitigates the cost. diff --git a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al new file mode 100644 index 0000000..48318a9 --- /dev/null +++ b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al @@ -0,0 +1,15 @@ +codeunit 50223 "Perf Sample CalcSums Bad" +{ + procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetRange("Customer No.", CustomerNo); + // One SQL query per row over a 10M-row ledger. + if CustLedgerEntry.FindSet() then + repeat + CustLedgerEntry.CalcFields("Remaining Amount"); + Total += CustLedgerEntry."Remaining Amount"; + until CustLedgerEntry.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al new file mode 100644 index 0000000..2e1f367 --- /dev/null +++ b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al @@ -0,0 +1,11 @@ +codeunit 50222 "Perf Sample CalcSums Good" +{ + procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetRange("Customer No.", CustomerNo); + CustLedgerEntry.CalcSums("Remaining Amount"); + Total := CustLedgerEntry."Remaining Amount"; + end; +} diff --git a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md new file mode 100644 index 0000000..7d0752f --- /dev/null +++ b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [calcfields, calcsums, loop, flowfield, n-plus-one, aggregation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use CalcSums to aggregate, not CalcFields inside a loop + +## Description + +`CalcFields` materializes FlowField values for one record. Each call against a persistent table is "a separate SQL query"; running it inside a `repeat ... until Next() = 0` over a large table issues one query per row on top of the iteration itself. `CalcSums` answers the same aggregation question — "give me the sum of this FlowField over the filtered set" — as a single SQL statement. Per the upstream guidance, `CalcFields` inside loops on large persistent tables is "a performance problem"; the aggregation form is `CalcSums()`. + +## Best Practice + +When the procedure totals a FlowField (or several) across a filtered set, set the filters, then call `CalcSums("Field 1", "Field 2", ...)`. The platform issues one query; the result is read off the record's FlowField slot. Single `CalcFields` outside loops is fine, and `CalcFields` on the current row in a page's `OnAfterGetRecord` or in `OnValidate` is the standard pattern — those are per-action, not per-row over a large set. + +See sample: `calcsums-instead-of-calcfields-in-loop.good.al`. + +## Anti Pattern + +`if CustLedgerEntry.FindSet() then repeat CustLedgerEntry.CalcFields("Remaining Amount"); Total += CustLedgerEntry."Remaining Amount"; until CustLedgerEntry.Next() = 0;` — exactly the upstream-flagged shape. The iteration is the cheap part; the per-row `CalcFields` is what scales linearly with table size. + +See sample: `calcsums-instead-of-calcfields-in-loop.bad.al`. diff --git a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.good.al b/microsoft/knowledge/performance/combine-multiple-modifyall-calls.good.al deleted file mode 100644 index e1ecaad..0000000 --- a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.good.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 51206 "Perf Sample CombineMA Good" -{ - procedure UpdateTolerance(DocumentNo: Code[20]; ToleranceAmount: Decimal) - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - CustLedgerEntry.SetRange("Document No.", DocumentNo); - CustLedgerEntry.SetRange(Open, true); - if CustLedgerEntry.FindSet(true) then - repeat - CustLedgerEntry."Accepted Payment Tolerance" := ToleranceAmount; - CustLedgerEntry."Accepted Pmt. Disc. Tolerance" := false; - CustLedgerEntry.Modify(false); - until CustLedgerEntry.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.md b/microsoft/knowledge/performance/combine-multiple-modifyall-calls.md deleted file mode 100644 index 21c32ff..0000000 --- a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [modifyall, bulk-update, filter, scan, recordset] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Combine multiple ModifyAll calls on the same recordset into a single pass - -## Description - -`ModifyAll(Field, Value)` issues a SQL UPDATE against every row matching the record variable's current filters, setting one field. Calling it twice on the same filtered recordset — once per field to update — produces two separate UPDATE statements, each of which has to re-locate the matching rows through the index. On a ledger-entry-scale table with ten million rows and a filter that matches a thousand, the overhead is not a doubling of the update cost but a doubling of the more expensive row-location cost. A single `FindSet(true)` + set-by-set assignment + `Modify(false)` completes both field changes in one pass. - -## Best Practice - -When more than one field needs to change on the same filtered recordset, iterate once with `FindSet(true)` and assign all fields per row. Reserve ModifyAll for the case where a single field change covers the whole update. If the filter set is truly huge and the trigger behaviour differs between fields, consider splitting with concrete evidence — otherwise the single-pass loop wins. - -See sample: `combine-multiple-modifyall-calls.good.al`. - -## Anti Pattern - -Applying `SetRange` against `CustLedgerEntry` on `"Document No."` and then calling `ModifyAll("Accepted Payment Tolerance", ...)` followed by `ModifyAll("Accepted Pmt. Disc. Tolerance", false)` — two scans over the same filtered rows. On Cust. Ledger Entry with production-scale data the redundant second scan is the dominant cost. - -See sample: `combine-multiple-modifyall-calls.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-flag-performance-on-bounded-tables.md b/microsoft/knowledge/performance/do-not-flag-performance-on-bounded-tables.md deleted file mode 100644 index 216e727..0000000 --- a/microsoft/knowledge/performance/do-not-flag-performance-on-bounded-tables.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [setup-table, temporary, bounded-table, metadata, migration, false-positive] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not flag performance on inherently bounded tables - -## Description - -Several categories of Business Central tables are so small, so rarely accessed, or so in-memory that performance heuristics that make sense on Item Ledger Entry produce noise when applied to them. Temporary records (`TableType = Temporary`, `SourceTableTemporary = true`) live in memory and any access pattern is fast. Singleton setup tables (`Sales & Receivables Setup`, `General Ledger Setup`, `*Setup` tables generally) hold one row per company. Small bounded tables — enum mappings, permission objects, Role IDs — count in the dozens. System metadata tables (`TableMetadata`, `Field`, `AllObjWithCaption`) are bounded by the object catalog. Admin, Migration, Setup, Wizard, and Hybrid* pages are used infrequently with small datasets. - -## Best Practice - -Skip performance findings on these categories unless the code is specifically pathological (unbounded loop that multiplies cost non-linearly). A missing SetLoadFields on a singleton Setup table is not a finding. A Count on a 30-row permission mapping is not a finding. An admin page that iterates a bounded list once per invocation is not a finding. Reserving reviewer attention for the tables where it matters is half the value of the heuristics — noise on bounded tables trains authors to ignore the signal. - -## Anti Pattern - -Flagging `SalesReceivablesSetup.Get()` followed by `SetLoadFields()` on a handful of fields as "missing partial record optimization". Flagging a `FindSet` + loop on `Role ID` mapping because the loop has no SetCurrentKey. Flagging a Migration codeunit for writing many records, when the entire migration runs once per customer. All three burn author attention on cases that are not regressions. diff --git a/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.bad.al b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.bad.al new file mode 100644 index 0000000..79c524e --- /dev/null +++ b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.bad.al @@ -0,0 +1,10 @@ +codeunit 50235 "Perf Sample LockReadOnly Bad" +{ + procedure GetStatus(var AgentStatus: Record "Agent Status"): Boolean + begin + // Read-only path, yet every caller's transaction now acquires UPDLOCK + // on Agent Status for the remainder of the transaction. + AgentStatus.LockTable(); + exit(AgentStatus.Get()); + end; +} diff --git a/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.good.al b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.good.al new file mode 100644 index 0000000..3f2a3a3 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.good.al @@ -0,0 +1,14 @@ +codeunit 50234 "Perf Sample LockReadOnly Good" +{ + procedure GetStatus(var AgentStatus: Record "Agent Status"): Boolean + begin + if AgentStatus.Get() then + exit(true); + AgentStatus.LockTable(); + if not AgentStatus.Get() then begin + AgentStatus.Init(); + AgentStatus.Insert(); + end; + exit(true); + end; +} diff --git a/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md new file mode 100644 index 0000000..f25af2e --- /dev/null +++ b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [locktable, read-only, helper, contention, transaction] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not LockTable in a read-only procedure + +## Description + +`LockTable` is a transaction-wide signal: from the call onward, every read against that table in the same transaction acquires `UPDLOCK`. Per the upstream guidance, "`LockTable()` before Modify/Insert/Delete in the same procedure is the correct pattern" — locking the read against the write that follows is what the call exists for. The anti-pattern is "`LockTable()` in read-only procedures — unnecessary lock contention": the procedure never writes, but the lock cost is paid by everyone sharing the transaction. + +## Best Practice + +Reserve `LockTable` for the read directly before a `Modify`, `Insert`, or `Delete` that depends on the read value. If a helper is sometimes called for reading and sometimes for writing, split it into separate read and write paths and call `LockTable` only on the write path. For read-only existence checks or lookups, the right primitive is `ReadIsolation` (see `prefer-readisolation-over-locktable-for-reads.md`). + +See sample: `do-not-locktable-in-read-only-procedure.good.al`. + +## Anti Pattern + +A pure getter that opens with `Rec.LockTable();`. Every caller's transaction now acquires `UPDLOCK` on that table for every subsequent read until commit. The contention shows up as blocking on unrelated sessions whose own code path looks innocent — the locker is invisible to the blocked reader. + +See sample: `do-not-locktable-in-read-only-procedure.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.bad.al b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.bad.al new file mode 100644 index 0000000..944ea99 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.bad.al @@ -0,0 +1,17 @@ +page 50247 "Perf Sample WriteScroll Bad" +{ + PageType = List; + SourceTable = Customer; + + trigger OnAfterGetRecord() + begin + // One DB write per row displayed, every time the user scrolls. + Rec."Reminder Terms Code" := CalcReminderTerms(); + Rec.Modify(); + end; + + local procedure CalcReminderTerms(): Code[10] + begin + exit(''); + end; +} diff --git a/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.good.al b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.good.al new file mode 100644 index 0000000..751875c --- /dev/null +++ b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.good.al @@ -0,0 +1,18 @@ +page 50246 "Perf Sample WriteScroll Good" +{ + PageType = List; + SourceTable = Customer; + + var + ShowWarning: Boolean; + + trigger OnAfterGetRecord() + begin + ShowWarning := CalcWarning(); + end; + + local procedure CalcWarning(): Boolean + begin + exit(false); + end; +} diff --git a/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md new file mode 100644 index 0000000..9de0903 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [page-trigger, onaftergetrecord, modify, display, scroll, db-write] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not Modify inside OnAfterGetRecord + +## Description + +A list page's `OnAfterGetRecord` fires once per visible row, every time the user scrolls, sorts, or refreshes. A `Modify` inside that trigger means a database write per row displayed. Per the upstream guidance, "`Modify()` here means a DB write on every scroll. Use page variables for display-only state instead." `OnAfterGetCurrRecord` (single record on selection), `OnOpenPage`, and `OnInit` fire once or at much lower frequency and tolerate one-time setup logic. + +## Best Practice + +When the trigger needs to compute display-only state per row, write the result into a page variable (a global on the page object) rather than back to the database. Reserve `Modify` for triggers that fire on an explicit user action — `OnAction`, validation triggers, `OnQueryClosePage` — where one action maps to one write. + +See sample: `do-not-modify-in-onaftergetrecord.good.al`. + +## Anti Pattern + +`trigger OnAfterGetRecord() begin Rec."Warning Flag" := CalcWarning(); Rec.Modify(); end;` — on a list page over a moderately sized table, scrolling through fifty rows produces fifty writes. The page feels slow, the table accumulates churn, and the warning flag — which is recomputed on every refresh anyway — never needed persistence. + +See sample: `do-not-modify-in-onaftergetrecord.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.bad.al b/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.bad.al deleted file mode 100644 index fbf5047..0000000 --- a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.bad.al +++ /dev/null @@ -1,15 +0,0 @@ -pageextension 51209 "Perf Sample NoModifyOAGR Bad" extends "Customer List" -{ - trigger OnAfterGetRecord() - begin - // Every scroll writes to the database. Every OnModify subscriber on - // Customer fires alongside. Write volume scales with mouse-wheel speed. - Rec."Last Warning Flag" := CalcWarning(); - Rec.Modify(); - end; - - local procedure CalcWarning(): Boolean - begin - exit(false); - end; -} diff --git a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.good.al b/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.good.al deleted file mode 100644 index 8e4d641..0000000 --- a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.good.al +++ /dev/null @@ -1,35 +0,0 @@ -page 51208 "Perf Sample NoModifyOAGR Good" -{ - PageType = List; - SourceTable = Customer; - - layout - { - area(Content) - { - repeater(Group) - { - field("No."; Rec."No.") { ApplicationArea = All; } - field(WarningFlag; ShowWarning) - { - ApplicationArea = All; - Caption = 'Warning'; - } - } - } - } - - trigger OnAfterGetRecord() - begin - // Page-local variable. No database write per row. - ShowWarning := CalcWarning(Rec); - end; - - var - ShowWarning: Boolean; - - local procedure CalcWarning(var Customer: Record Customer): Boolean - begin - exit(false); - end; -} diff --git a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.md b/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.md deleted file mode 100644 index 8133229..0000000 --- a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [onaftergetrecord, modify, page, trigger, write-per-scroll] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not Modify records inside OnAfterGetRecord - -## Description - -`OnAfterGetRecord` fires for every row the page or repeater renders. On a list page the user scrolls through, the trigger runs hundreds of times per second. A `Modify()` call inside the trigger writes to the database for every row scrolled past — the user's mouse wheel generates the write storm, and the effect compounds with every other subscriber that reacts to the OnModify event. The database activity is usually invisible to the author in development, because the list page loads ten rows; on a production tenant scrolling through thousands of rows, the page becomes the top source of write volume. - -## Best Practice - -Derive display-only state into a page-level variable and bind that variable to the field control instead of writing to `Rec`. If the computed value is genuinely a stored attribute of the record, compute it once at the authoring site (OnValidate, OnInsert) and display the stored value on the list — do not recompute and rewrite on every render. - -See sample: `do-not-modify-records-in-onaftergetrecord.good.al`. - -## Anti Pattern - -An OnAfterGetRecord body that assigns a computed value to `Rec."Warning Flag"` and calls `Rec.Modify()` so the flag persists. The write fires per scroll, per user, per second — and every subscriber on the Rec's OnModify fires alongside. - -See sample: `do-not-modify-records-in-onaftergetrecord.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.bad.al b/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.bad.al deleted file mode 100644 index 0a5469a..0000000 --- a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.bad.al +++ /dev/null @@ -1,34 +0,0 @@ -page 51203 "Perf Sample ReGetRec Bad" -{ - PageType = List; - SourceTable = "Assembly Line"; - - layout - { - area(Content) - { - repeater(Group) - { - field("No."; Rec."No.") { ApplicationArea = All; } - } - } - } - - trigger OnAfterGetRecord() - var - AssemblyLineRec: Record "Assembly Line"; - begin - // Redundant Get. The page runtime already loaded this row into Rec. - // At list-page scale this fires hundreds of times per scroll. - AssemblyLineRec.Get(Rec."Document Type", Rec."Document No.", Rec."Line No."); - ShowWarning := CheckAvailability(AssemblyLineRec); - end; - - var - ShowWarning: Boolean; - - local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean - begin - exit(false); - end; -} diff --git a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.good.al b/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.good.al deleted file mode 100644 index 2594f54..0000000 --- a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.good.al +++ /dev/null @@ -1,30 +0,0 @@ -page 51202 "Perf Sample ReGetRec Good" -{ - PageType = List; - SourceTable = "Assembly Line"; - - layout - { - area(Content) - { - repeater(Group) - { - field("No."; Rec."No.") { ApplicationArea = All; } - } - } - } - - trigger OnAfterGetRecord() - begin - // Rec already holds the current row's values; no Get needed. - ShowWarning := CheckAvailability(Rec); - end; - - var - ShowWarning: Boolean; - - local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean - begin - exit(false); - end; -} diff --git a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.md b/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.md deleted file mode 100644 index 120b079..0000000 --- a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [onaftergetrecord, get, rec, page-runtime, redundant-fetch] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not re-Get the current record inside OnAfterGetRecord - -## Description - -The page runtime loads the current record before firing `OnAfterGetRecord` — `Rec` already holds the row's values when the trigger body runs. Calling `Rec.Get(...)` (or any equivalent Get against the same key) inside the trigger issues a second database round-trip for data the runtime just fetched. On a list page that displays hundreds of rows during a scroll, this turns into hundreds of wasted round-trips per user interaction. The same concern applies to `OnAfterGetCurrRecord` on card and document pages, though the impact is smaller because the trigger fires per selection rather than per row. - -## Best Practice - -Read from `Rec` directly. When a helper method needs a different record, pass `Rec` as an argument or let the helper fetch its own lookup once; do not re-Get the current row. If the code truly needs a fresh value because it was modified by another session, design the refresh explicitly — document it in a comment — rather than paying the cost on every trigger fire. - -See sample: `do-not-re-get-rec-inside-onaftergetrecord.good.al`. - -## Anti Pattern - -An `OnAfterGetRecord` trigger body that starts with `AssemblyLineRec.Get("Document Type", "Document No.", "Line No.")` for the same keys the page runtime has already used — the Get restates what `Rec` already holds. Replace with a direct call against `Rec` (`CheckAvailability(Rec)`). - -See sample: `do-not-re-get-rec-inside-onaftergetrecord.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.bad.al b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.bad.al new file mode 100644 index 0000000..1f99d7c --- /dev/null +++ b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.bad.al @@ -0,0 +1,12 @@ +page 50249 "Perf Sample TempAPI Bad" +{ + PageType = API; + APIPublisher = 'perf'; + APIGroup = 'sample'; + APIVersion = 'v1.0'; + EntityName = 'outboxEmail'; + EntitySetName = 'outboxEmails'; + SourceTable = "Sent Email"; + // SourceTableTemporary removed — every request now hits SQL. + DelayedInsert = true; +} diff --git a/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.good.al b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.good.al new file mode 100644 index 0000000..0ec95f7 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.good.al @@ -0,0 +1,12 @@ +page 50248 "Perf Sample TempAPI Good" +{ + PageType = API; + APIPublisher = 'perf'; + APIGroup = 'sample'; + APIVersion = 'v1.0'; + EntityName = 'outboxEmail'; + EntitySetName = 'outboxEmails'; + SourceTable = "Sent Email"; + SourceTableTemporary = true; + DelayedInsert = true; +} diff --git a/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md new file mode 100644 index 0000000..a7215be --- /dev/null +++ b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [sourcetabletemporary, api-page, temporary, persistent, in-memory] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Removing SourceTableTemporary on an API page switches it from in-memory to persistent + +## Description + +`SourceTableTemporary = true` on a page makes the page's record buffer in-memory only — reads and writes do not touch SQL. The same applies to `TableType = Temporary` on a record. Removing either turns operations that were memory accesses into database round-trips. Per the upstream guidance, the change is "potentially increasing DB load for high-volume paths (API pages, background tasks)" — and on API pages especially, the change is invisible at the page definition but visible at production scale. + +## Best Practice + +If a page or record was declared temporary on purpose — to buffer payloads, accept synthetic rows, or expose computed data through an API surface without persisting it — keep it temporary. When removing the property looks necessary, audit the call sites first: a temporary API page is often consumed by integrations that issue many calls per minute, and the round-trip cost is paid per call. If persistence is genuinely required, weigh storage and lock cost against alternatives (a regular table the API page reads from, an event-driven write). + +See sample: `do-not-remove-sourcetabletemporary-from-api-page.good.al`. + +## Anti Pattern + +Dropping `SourceTableTemporary = true` from an API page to "simplify" it, without revisiting the access pattern. The page begins issuing real SQL on every request; locks now contend with other writers; bulk integrations slow proportionally. The same trap exists for a record that was `TableType = Temporary` and gets demoted to a persistent table to make a debugger view easier. + +See sample: `do-not-remove-sourcetabletemporary-from-api-page.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-retarget-flowfield-calcformula-to-larger-tables.md b/microsoft/knowledge/performance/do-not-retarget-flowfield-calcformula-to-larger-tables.md deleted file mode 100644 index 6d1a572..0000000 --- a/microsoft/knowledge/performance/do-not-retarget-flowfield-calcformula-to-larger-tables.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [flowfield, calcformula, regression, source-table, sift] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not retarget a FlowField's CalcFormula to a larger source table - -## Description - -A FlowField's CalcFormula is evaluated every time the field is read — every time the page renders, every CalcFields call, every list page filter that references the field. Changing the CalcFormula's source table from a smaller, bounded, or already-filtered table to a larger unfiltered one multiplies the per-read cost. A common shape is the refactor from "Posted X" to "X" — the unposted line table is typically an order of magnitude larger and carries rows that the original FlowField never considered. The change compiles and may look like a simple scope widening; the performance impact is not visible until production load. - -## Best Practice - -When a FlowField CalcFormula changes source table, evaluate the before/after row counts, ensure a SIFT key exists on the new source that matches the formula's filters (see `add-sift-keys-for-flowfields`), and verify no existing callers rely on the tighter scope. If the widening is intentional, the corresponding SIFT keys on the new source must ship in the same PR. - -## Anti Pattern - -Changing a `sum("Posted Expense Report Line"."Amount" where(...))` formula to `sum("Expense Report Line"."Amount" where(...))` without touching the source table's keys. Every list page and dashboard that reads the FlowField now aggregates over the unposted table too, almost always without a supporting SIFT key. diff --git a/microsoft/knowledge/performance/filter-before-find.md b/microsoft/knowledge/performance/filter-before-find.md deleted file mode 100644 index f267761..0000000 --- a/microsoft/knowledge/performance/filter-before-find.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [filter, setrange, setfilter, findset, scan] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Filter before you find - -## Description - -Every call to FindSet, Find, or FindFirst on an unfiltered record variable scans the entire table. On hot tables (ledger entries, value entries, sales invoice lines) a production dataset can easily be millions of rows, so the cost of forgetting a filter is orders of magnitude worse than the cost of applying one. - -## Best Practice - -Apply SetRange or SetFilter to narrow the record set before calling FindSet or Find. The filters should match a key on the table (see set-current-key-to-match-filters). When iterating rows that belong to a parent record, set all key-field filters before the find call — never inside the repeat loop. - -See sample: `filter-before-find.good.al`. - -## Anti Pattern - -Calling FindSet with no filters and then discarding rows inside the loop with an if-statement forces the platform to read every row of the table before your code even runs. - -See sample: `filter-before-find.bad.al`. - diff --git a/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.bad.al b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.bad.al new file mode 100644 index 0000000..d80786b --- /dev/null +++ b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.bad.al @@ -0,0 +1,25 @@ +codeunit 50237 "Perf Sample FindSetTrue Bad" +{ + procedure NormalizeNames() + var + Customer: Record Customer; + begin + // Read takes a shared lock; the Modify then needs to upgrade — that gap + // is the deadlock window FindSet(true) was designed to close. + if Customer.FindSet() then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); + until Customer.Next() = 0; + end; + + procedure ReadOnlyOverlocked() + var + Customer: Record Customer; + begin + // No Modify in the loop, yet every row is read under UpdLock. + if Customer.FindSet(true) then + repeat + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.good.al b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.good.al new file mode 100644 index 0000000..bb6bf91 --- /dev/null +++ b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.good.al @@ -0,0 +1,23 @@ +codeunit 50236 "Perf Sample FindSetTrue Good" +{ + procedure NormalizeNames() + var + Customer: Record Customer; + begin + if Customer.FindSet(true) then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); + until Customer.Next() = 0; + end; + + procedure SumBalances() Total: Decimal + var + Customer: Record Customer; + begin + if Customer.FindSet() then + repeat + Total += Customer."Balance (LCY)"; + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md new file mode 100644 index 0000000..48a1deb --- /dev/null +++ b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [findset, updlock, readisolation, locking, modify, obsolete-syntax] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# FindSet(true) applies UpdLock on the read; the two-parameter form is obsolete + +## Description + +`FindSet()` and `FindSet(false)` are read-only — no locking. Per the upstream guidance, `FindSet(true)` "signifies the intent is to modify records" and "sets `ReadIsolation::UpdLock` on the record before finding rows." That is exactly the right shape when the loop body modifies each row: the read takes the same lock the modification will need, avoiding the deadlock window between an unlocked read and a later upgrade. The older two-parameter form `FindSet(ForUpdate, UpdateKey)` is obsolete — only the single-parameter signature should appear in new code. + +## Best Practice + +Use `FindSet(true)` only when the loop body genuinely modifies the iterated rows; use `FindSet()` (or `FindSet(false)`) when the loop only reads. Do not write `FindSet(true, true)` or `FindSet(true, false)` — the two-parameter form is the obsolete signature. + +See sample: `findset-true-applies-updlock-on-read.good.al`. + +## Anti Pattern + +`FindSet(true)` on a loop that does not modify the iterated rows takes an `UpdLock` the work does not need; competing readers and writers stall against a lock the loop never uses. The mirror anti-pattern is `FindSet()` (no parameter) on a loop that *does* modify each row — the read takes a shared lock, the `Modify` then needs to upgrade, and the gap between them is a deadlock candidate. + +See sample: `findset-true-applies-updlock-on-read.bad.al`. diff --git a/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.bad.al b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.bad.al new file mode 100644 index 0000000..f784979 --- /dev/null +++ b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.bad.al @@ -0,0 +1,15 @@ +tableextension 50226 "Perf Sample SIFT Bad Cust" extends Customer +{ + fields + { + // No SIFT key on Detailed Cust. Ledg. Entry for (Customer No.) with + // "Debit Amount" in SumIndexFields — the sum falls back to row-by-row + // aggregation over a ledger-scale table. + field(50226; "Perf Sample Total Debit"; Decimal) + { + FieldClass = FlowField; + CalcFormula = sum("Detailed Cust. Ledg. Entry"."Debit Amount" + where("Customer No." = field("No."))); + } + } +} diff --git a/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.good.al b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.good.al new file mode 100644 index 0000000..420a84a --- /dev/null +++ b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.good.al @@ -0,0 +1,23 @@ +tableextension 50224 "Perf Sample SIFT Good Ext" extends "Detailed Cust. Ledg. Entry" +{ + keys + { + key(PerfSampleByCustomer; "Customer No.", "Posting Date") + { + SumIndexFields = "Debit Amount"; + } + } +} + +tableextension 50225 "Perf Sample SIFT Good Cust" extends Customer +{ + fields + { + field(50225; "Perf Sample Total Debit"; Decimal) + { + FieldClass = FlowField; + CalcFormula = sum("Detailed Cust. Ledg. Entry"."Debit Amount" + where("Customer No." = field("No."))); + } + } +} diff --git a/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md new file mode 100644 index 0000000..3a63613 --- /dev/null +++ b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [flowfield, sumindexfields, sift, key, calcformula, aa0232] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A FlowField needs a source-table key that covers its CalcFormula + +## Description + +A FlowField is computed by SQL on demand. CodeCop AA0232 — "FlowFields should be indexed with SumIndexFields on corresponding keys" — captures the indexing requirement: the source table must declare a key that includes the fields the `CalcFormula` filters on, with the aggregated field listed in that key's `SumIndexFields`. When that alignment is in place, the platform answers `CalcFields`/`CalcSums` from SIFT; without it, the same query falls back to a row-by-row aggregation on what is often a ledger-scale table. Per the upstream guidance, "Missing SIFT indices cause performance issues on List pages." + +## Best Practice + +When introducing or changing a FlowField, walk the `CalcFormula`'s `WHERE` clause field by field and verify the source table has a key whose key fields cover those filters, with the aggregated field in `SumIndexFields`. The same applies when the destination side of the FlowField filter is a list-page column: the page filter triggers the FlowField on every visible row, and only SIFT keeps that affordable. + +See sample: `flowfield-source-key-needs-sumindexfields.good.al`. + +## Anti Pattern + +A `sum` FlowField against a large source table with no matching SIFT key. Each calculation aggregates rows directly; on a ledger-sized source the FlowField becomes the slowest column on every page that displays it. Pointing an existing FlowField's `CalcFormula` at a larger source table without verifying the new source's keys is the same trap a step removed — the upstream review guidance flags it as "CalcFormula changed to larger source table". + +See sample: `flowfield-source-key-needs-sumindexfields.bad.al`. diff --git a/microsoft/knowledge/performance/guard-before-get-not-after.good.al b/microsoft/knowledge/performance/guard-before-get-not-after.good.al deleted file mode 100644 index 5387f0f..0000000 --- a/microsoft/knowledge/performance/guard-before-get-not-after.good.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 51200 "Perf Sample GuardBeforeGet Good" -{ - procedure HandleLine(var PurchaseLine: Record "Purchase Line") - var - PurchaseHeader: Record "Purchase Header"; - begin - // Cheap in-memory check first. Get only when the subsequent code needs the header. - if PurchaseLine."Selected Alloc. Account No." = '' then - exit; - - if not PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No.") then - exit; - - // Work with PurchaseHeader. - end; -} diff --git a/microsoft/knowledge/performance/guard-before-get-not-after.md b/microsoft/knowledge/performance/guard-before-get-not-after.md deleted file mode 100644 index ea93e9d..0000000 --- a/microsoft/knowledge/performance/guard-before-get-not-after.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [get, guard, early-exit, wasted-fetch, conditional] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Place guard conditions before Get, not after - -## Description - -A `Record.Get(Key)` is a database round-trip. When the call site also contains an early-exit condition that may fire before the fetched record is used, the order of the two matters: `Get` first followed by a guard that may exit means every call pays the round-trip, including the calls that immediately return. Flipping the order — evaluate the guard first, `Get` only when needed — costs nothing in the happy path and turns the wasted round-trip into zero work on the exit path. The savings compound on hot tables and on code paths entered many times per user action. - -## Best Practice - -Evaluate cheap, in-memory conditions first. Only issue the `Get` (or `FindFirst`, `FindLast`) when the subsequent code actually needs the record's values. For complex procedures with multiple exit conditions, sort them cheapest-first: in-memory checks, then single-record lookups, then set iteration. - -See sample: `guard-before-get-not-after.good.al`. - -## Anti Pattern - -`PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); if PurchaseLine."Selected Alloc. Account No." = '' then exit;` — the Get fires on every call; the exit discards the result for every call where `Selected Alloc. Account No.` is blank. - -See sample: `guard-before-get-not-after.bad.al`. diff --git a/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.bad.al b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.bad.al new file mode 100644 index 0000000..15402a3 --- /dev/null +++ b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.bad.al @@ -0,0 +1,18 @@ +codeunit 50257 "Perf Sample EventGuard Bad" +{ + [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'Quantity', false, false)] + local procedure OnAfterValidateQuantity(var Rec: Record "Sales Line") + var + Item: Record Item; + begin + // Item.Get fires on every Quantity edit — including lines whose Type is + // not Item. No cheap guard, no SetLoadFields. + Item.Get(Rec."No."); + if Item."Item Category Code" <> '' then + RecalculatePrice(Rec, Item); + end; + + local procedure RecalculatePrice(var SalesLine: Record "Sales Line"; var Item: Record Item) + begin + end; +} diff --git a/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.good.al b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.good.al new file mode 100644 index 0000000..1530e97 --- /dev/null +++ b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.good.al @@ -0,0 +1,19 @@ +codeunit 50256 "Perf Sample EventGuard Good" +{ + [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'Quantity', false, false)] + local procedure OnAfterValidateQuantity(var Rec: Record "Sales Line") + var + Item: Record Item; + begin + if Rec.Type <> Rec.Type::Item then + exit; + Item.SetLoadFields("Item Category Code"); + if Item.Get(Rec."No.") then + if Item."Item Category Code" <> '' then + RecalculatePrice(Rec, Item); + end; + + local procedure RecalculatePrice(var SalesLine: Record "Sales Line"; var Item: Record Item) + begin + end; +} diff --git a/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md new file mode 100644 index 0000000..c466ffe --- /dev/null +++ b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [event-subscriber, guard, db-call, frequently-fired, validate] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Guard event subscribers with cheap checks before any database call + +## Description + +Event subscribers fire on every event matching their signature — for `OnAfterValidateEvent` on a hot field like `Sales Line.Quantity`, that is every quantity edit by every user. Per the upstream guidance, "Keep event subscriber code lightweight" and "Avoid database operations in frequently-fired events — guard with cheap checks first." A `Get` or `FindFirst` at the top of such a subscriber pays a database round-trip on every fire, including the calls for which the subscriber's work was not needed. + +## Best Practice + +Open the subscriber with an in-memory predicate that filters out the calls the subscriber does not handle — record type, document type, status, parameter-passed flags. Only after the cheap guard passes should the body issue a database call, and only with `SetLoadFields` for the columns the body actually reads. + +See sample: `guard-event-subscribers-before-db-call.good.al`. + +## Anti Pattern + +`[EventSubscriber(...'OnAfterValidateEvent', 'Quantity', ...)] local procedure ... var Item: Record Item; begin Item.Get(Rec."No."); if Item.HasCustomPricing() then ...;` — `Item.Get` runs on every quantity change, including changes to lines whose `Type` is not `Item`. A pre-check `if Rec.Type <> Rec.Type::Item then exit;` ahead of the `Get` removes most of the calls. + +See sample: `guard-event-subscribers-before-db-call.bad.al`. diff --git a/microsoft/knowledge/performance/hidden-flowfields-still-calculate-on-pages.md b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-on-pages.md deleted file mode 100644 index 184e363..0000000 --- a/microsoft/knowledge/performance/hidden-flowfields-still-calculate-on-pages.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [flowfield, visible, enabled, page, calcfields, feature-management] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Hidden FlowFields still calculate on pages - -## Description - -Setting `Visible = false` or `Enabled = false` on a FlowField hides the control but does not suppress the calculation. The server still runs the underlying CalcFields for every row the page renders. On a list page over a large table, the invisible column keeps consuming the same SQL as a visible one — the hiding is cosmetic only, and a diff that "turns off" an expensive FlowField by flipping `Visible` fixes nothing on the server. - -There are two correct remedies. The durable one is to remove the FlowField from the page or page-extension definition entirely — property toggles are not enough. The environment-level one, available where supported, is the **Calculate only visible FlowFields** feature in Feature Management; when enabled, the AL runtime skips calculation for non-visible FlowFields on pages. The feature is opt-in and administrator-controlled, so code cannot assume it is active. - -## Best Practice - -Remove unused or hidden FlowFields from the page or page extension. If the field is needed for some users but expensive for others, factor into a dedicated page variant rather than hiding it in place. Do not rely on `Visible = false` as a performance fix unless the tenant has enabled the Calculate only visible FlowFields feature and that assumption is acceptable. - -## Anti Pattern - -A performance PR that sets `Visible = false` on an expensive FlowField on a list page and claims the column no longer impacts load time. The control disappears from the UI, the CalcFields still runs for every row, and the list page stays slow. diff --git a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.bad.al b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.bad.al deleted file mode 100644 index ea3fd43..0000000 --- a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.bad.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50140 "Perf Sample Subscriber Bad" -{ - [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'No.', false, false)] - local procedure HeavyWorkOnSalesLineNo(var Rec: Record "Sales Line"; var xRec: Record "Sales Line") - var - HttpClient: HttpClient; - HttpResponse: HttpResponseMessage; - begin - // synchronous external call on a hot event - HttpClient.Get('https://example.com/validate?no=' + Rec."No.", HttpResponse); - end; -} diff --git a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.good.al b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.good.al deleted file mode 100644 index df25047..0000000 --- a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.good.al +++ /dev/null @@ -1,20 +0,0 @@ -codeunit 50930 "Perf Sample Subscriber Good" -{ - [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'No.', false, false)] - local procedure OnAfterValidateSalesLineNo(var Rec: Record "Sales Line") - var - Item: Record Item; - begin - if Rec.Type <> Rec.Type::Item then - exit; - - Item.SetLoadFields("Costing Method"); - if Item.Get(Rec."No.") then - if Item."Costing Method" = Item."Costing Method"::Specific then - UpdateSpecificCostingState(Rec); - end; - - local procedure UpdateSpecificCostingState(var SalesLine: Record "Sales Line") - begin - end; -} diff --git a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md deleted file mode 100644 index 4aa59fb..0000000 --- a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [event, subscriber, publisher, extension] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Keep event subscribers lightweight - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Event subscribers run synchronously on the publisher's thread. If a subscriber does heavy work — a database query, a web service call, a layout render — every caller of the publisher pays that cost. Subscribers on hot events (OnAfterValidate on common fields, OnBeforeInsert on ledger-entry-like tables) can multiply a small per-call cost into a system-wide regression. - -## Best Practice - -Keep subscribers small: guard early with inexpensive checks on the publisher record before doing any database work, defer heavy work to a task queue or a background session, and cache results across invocations when the data is stable. In hot events, a cheap `Type`/`Status`/`IsTemporary` exit before a `Get` or `FindFirst` is often the difference between a rare lookup and an N+1 query across every posted line. - -See sample: `keep-event-subscribers-lightweight.good.al`. - -## Anti Pattern - -Calling an external web service, running a report, or iterating a large table from inside an event subscriber on a hot publisher makes every operation on that publisher as slow as the heaviest subscriber. - -See sample: `keep-event-subscribers-lightweight.bad.al`. - diff --git a/microsoft/knowledge/performance/keep-oncompanyopen-subscribers-lightweight.md b/microsoft/knowledge/performance/keep-oncompanyopen-subscribers-lightweight.md deleted file mode 100644 index 16a2061..0000000 --- a/microsoft/knowledge/performance/keep-oncompanyopen-subscribers-lightweight.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [oncompanyopen, oncompanyopencompleted, session, sign-in, subscriber, startup] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Keep OnCompanyOpen and OnCompanyOpenCompleted subscribers lightweight - -## Description - -`OnCompanyOpen` and `OnCompanyOpenCompleted` are raised every time a session is created — not only for interactive sign-ins, but also for every web service call, every job queue entry, every scheduled task, and every page background task. The session cannot run any AL code until every subscriber on these events has finished. Interactive users see a spinner; web service callers see elevated response times; background sessions sit idle waiting to start. - -Anything expensive in these subscribers is paid per session across the whole tenant. The two patterns that typically cause production incidents are outgoing HTTP calls to external services — which block AL execution until they complete (or time out) — and long-running SQL over large tables. An external service that is slow or unreachable turns into a tenant-wide sign-in outage, not a degraded feature. - -The code often looks harmless in review: a telemetry ping, a configuration refresh, a "just make sure the setup record exists" Get-or-Insert. Multiplied by session creations per minute, each of these becomes the critical path of sign-in. - -## Best Practice - -Keep `OnCompanyOpen` and `OnCompanyOpenCompleted` subscribers short and in-memory. Defer work that touches external services or large tables to a Page Background Task, a job queue entry, or a lazy first-use path. If an outgoing HTTP call in startup is truly unavoidable, set an aggressive timeout so a failing endpoint cannot stall session creation. - -## Anti Pattern - -An `OnCompanyOpen` subscriber that calls an external licensing API over HttpClient without a tight timeout. When the endpoint is slow, every new session in the tenant — UI, API, background — waits on the HTTP call before it can run any AL. diff --git a/microsoft/knowledge/performance/keep-sourcetabletemporary-on-api-and-background-pages.md b/microsoft/knowledge/performance/keep-sourcetabletemporary-on-api-and-background-pages.md deleted file mode 100644 index 9702960..0000000 --- a/microsoft/knowledge/performance/keep-sourcetabletemporary-on-api-and-background-pages.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [sourcetabletemporary, tabletype, temporary, api-page, persistence, regression] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not remove SourceTableTemporary or TableType = Temporary without understanding the impact - -## Description - -`SourceTableTemporary = true` on a page, and `TableType = Temporary` on a table, mean the underlying record operates in memory — Insert/Modify/Delete mutate the session buffer, not the database. Removing either property converts the same operations to real SQL writes. On an API page that external callers hit at high frequency, on a background task that processes thousands of records, or on a UI page that composes an in-memory list for display, the change from temporary to persistent can turn a lightweight operation into a major source of database load. The refactor is easy to propose ("why is this temporary?") and expensive to regret. - -## Best Practice - -When a diff removes `SourceTableTemporary = true` or `TableType = Temporary`, require justification explaining why persistence is now required and what paths still write. Review the callers for unexpected new writes, transaction scope, trigger fires, and contention. Keep the property unless the change genuinely needs persistence; an unused-looking temporary table on a bounded page is usually there for a reason. - -## Anti Pattern - -A cleanup PR that deletes `SourceTableTemporary = true` from an API page "because the source table already exists". The API now writes to the real table on every call, every consumer's requests reach the database, and the incidental side-effects in the source table's triggers start firing across tenants. diff --git a/microsoft/knowledge/performance/locktable-applies-to-whole-table-in-transaction.md b/microsoft/knowledge/performance/locktable-applies-to-whole-table-in-transaction.md deleted file mode 100644 index d6bf687..0000000 --- a/microsoft/knowledge/performance/locktable-applies-to-whole-table-in-transaction.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [locktable, updlock, transaction, contention, scope] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# LockTable applies to the whole table for the rest of the transaction - -## Description - -`Record.LockTable` is commonly read as "lock this record variable", but it does not work that way. The call applies `WITH (UPDLOCK)` to every subsequent read against the underlying table in the current transaction, regardless of which record variable issues the read. If `ItemA.LockTable` runs, then an unrelated `ItemB` variable on `Item`, a `FindSet` from a helper codeunit on `Item`, and any nested code that reads `Item` all acquire UPDLOCK until the transaction commits. - -The consequence is that calling LockTable early in a transaction — for example at the top of a routine "to be safe" — upgrades every read of that table for the remainder of the transaction to a writer-blocking lock. Contention scales with transaction length, not with how many writes the code actually performs. A LockTable deep in a call graph can silently serialize readers that never touch the LockTable-ing variable. - -## Best Practice - -Defer `LockTable` as late as possible and place it as close to the actual modification as you can. Keep transactions short so the UPDLOCK window is narrow. Do not add LockTable preemptively to "protect" a read that is not part of a read-modify-write sequence — the correct tool for read consistency is an isolation level (see Record.ReadIsolation), not a write lock. - -## Anti Pattern - -A procedure that calls `Rec.LockTable()` at the start "before doing anything" and then performs a long read-heavy validation before the eventual Modify. Every read in the validation now takes UPDLOCK on the whole table, and every other session that tries to read the same table waits on this transaction. diff --git a/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.bad.al b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.bad.al new file mode 100644 index 0000000..edce742 --- /dev/null +++ b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.bad.al @@ -0,0 +1,26 @@ +table 50227 "Perf Sample FA Journal Tmpl" +{ + fields + { + field(1; Name; Code[10]) { } + field(40; "No. of Lines"; Integer) + { + FieldClass = FlowField; + // Source key below has MaintainSQLIndex = false: SIFT cannot + // function, so this COUNT runs without a SQL index. + CalcFormula = count("FA Journal Line" + where("Journal Template Name" = field(Name))); + } + } +} + +tableextension 50228 "Perf Sample FA Jnl Line Ext" extends "FA Journal Line" +{ + keys + { + key(PerfSampleByTemplate; "Journal Template Name", "Journal Batch Name") + { + MaintainSQLIndex = false; + } + } +} diff --git a/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md new file mode 100644 index 0000000..e540859 --- /dev/null +++ b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [maintainsqlindex, key, sift, flowfield, sum, count, table-scan] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# MaintainSQLIndex = false on a key disables SIFT for FlowFields that depend on it + +## Description + +`MaintainSQLIndex = false` on a key tells the platform not to materialize that key as a SQL index. Per the upstream guidance, when a FlowField's source key carries that property, "SIFT cannot function, COUNT/SUM will table-scan." The flag is sometimes set to save write-path cost on a rarely-queried key, but if a `CalcFormula` aggregates through that exact key, the FlowField loses its index — every `CalcFields`/`CalcSums`/list-page filter that triggers it runs without one. + +## Best Practice + +When changing a key property to `MaintainSQLIndex = false`, find every FlowField whose `CalcFormula` filters on that key and verify another key covers the same fields. When adding a FlowField whose source table has only a `MaintainSQLIndex = false` key for its filter columns, add a fully-indexed key (or accept that the FlowField cannot ride SIFT and reshape the design — see `flowfield-source-key-needs-sumindexfields.md`). + +See sample: `maintainsqlindex-false-breaks-flowfield-sift.bad.al`. + +## Anti Pattern + +A FlowField whose `CalcFormula`'s `WHERE` columns line up with a key that has `MaintainSQLIndex = false`. The schema looks correct — the key exists, the SumIndexFields are listed — but at runtime the platform has no SQL index to use, and the aggregation table-scans on every invocation. diff --git a/microsoft/knowledge/performance/maintainsqlindex-false-disables-sift.md b/microsoft/knowledge/performance/maintainsqlindex-false-disables-sift.md deleted file mode 100644 index acd3eae..0000000 --- a/microsoft/knowledge/performance/maintainsqlindex-false-disables-sift.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [maintainsqlindex, sift, sumindexfields, flowfield, calcsums, key] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# MaintainSQLIndex = false on a key disables SIFT for the FlowFields that depend on it - -## Description - -SIFT relies on the underlying SQL index being maintained by the platform. Setting `MaintainSQLIndex = false` on a key drops the SQL index without dropping the AL key declaration — the key compiles, FlowFields that reference its SumIndexFields compile, and CalcSums calls against matching filters compile. At runtime, however, the SIFT optimization silently cannot engage, and every aggregate falls back to a table scan. The symptom is a FlowField whose read time degrades linearly with row count, with no code-level signal pointing at the key property as the cause. - -## Best Practice - -Keep `MaintainSQLIndex = true` (the default) on any key whose SumIndexFields back a FlowField or that callers use with CalcSums. When a key is genuinely unused and the SQL index cost is the concern, remove the key entirely rather than leaving it in place with `MaintainSQLIndex = false`. If the FlowField is still needed, pick a different key that is maintained. - -## Anti Pattern - -A source-table key declared with `SumIndexFields` and `MaintainSQLIndex = false`, with a FlowField referencing those sum fields. The FlowField appears to work in development against small datasets and becomes a full table scan on production-scale data, with no error message and no obvious culprit in the code under review. diff --git a/microsoft/knowledge/performance/only-fetch-records-you-use.bad.al b/microsoft/knowledge/performance/only-fetch-records-you-use.bad.al deleted file mode 100644 index ef3401f..0000000 --- a/microsoft/knowledge/performance/only-fetch-records-you-use.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50107 "Perf Sample OnlyFetchUsed Bad" -{ - procedure CustomerHasEntries(CustomerNo: Code[20]): Boolean - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - CustLedgerEntry.SetRange("Customer No.", CustomerNo); - exit(CustLedgerEntry.FindSet()); - end; -} diff --git a/microsoft/knowledge/performance/only-fetch-records-you-use.good.al b/microsoft/knowledge/performance/only-fetch-records-you-use.good.al deleted file mode 100644 index a989e28..0000000 --- a/microsoft/knowledge/performance/only-fetch-records-you-use.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50106 "Perf Sample OnlyFetchUsed Good" -{ - procedure CustomerHasEntries(CustomerNo: Code[20]): Boolean - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - CustLedgerEntry.SetRange("Customer No.", CustomerNo); - exit(not CustLedgerEntry.IsEmpty()); - end; -} diff --git a/microsoft/knowledge/performance/only-fetch-records-you-use.md b/microsoft/knowledge/performance/only-fetch-records-you-use.md deleted file mode 100644 index 3a1e40e..0000000 --- a/microsoft/knowledge/performance/only-fetch-records-you-use.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [findset, get, aa0175, wasted-fetch, read] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Only fetch records you use - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -CodeCop rule AA0175 flags code that retrieves a record and then does not use it. Every Find, FindSet, FindFirst, FindLast, or Get has a cost: the platform reads rows from SQL, materializes them, and transports them to the AL runtime. A call whose result is never read is wasted work, and on hot tables that work is never free. - -## Best Practice - -Retrieve a record only when you need one or more of its field values. When you only need to know whether at least one row matches a filter, use IsEmpty (see use-isempty-for-existence-checks). When you only need a subset of fields, use SetLoadFields (see use-setloadfields-for-partial-records). - -See sample: `only-fetch-records-you-use.good.al`. - -## Anti Pattern - -Calling FindSet or Get and then ignoring the result, or using it only as a boolean existence test, performs the full fetch and throws the data away. - -See sample: `only-fetch-records-you-use.bad.al`. - diff --git a/microsoft/knowledge/performance/pair-findset-with-next-loop.bad.al b/microsoft/knowledge/performance/pair-findset-with-next-loop.bad.al new file mode 100644 index 0000000..ec5eec3 --- /dev/null +++ b/microsoft/knowledge/performance/pair-findset-with-next-loop.bad.al @@ -0,0 +1,13 @@ +codeunit 50209 "Perf Sample FindSetNext Bad" +{ + procedure SumCustomerBalances() Total: Decimal + var + Customer: Record Customer; + begin + // AA0233: FindFirst paired with Next — single-row API used to iterate. + if Customer.FindFirst() then + repeat + Total += Customer."Balance (LCY)"; + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/pair-findset-with-next-loop.good.al b/microsoft/knowledge/performance/pair-findset-with-next-loop.good.al new file mode 100644 index 0000000..955c05b --- /dev/null +++ b/microsoft/knowledge/performance/pair-findset-with-next-loop.good.al @@ -0,0 +1,18 @@ +codeunit 50208 "Perf Sample FindSetNext Good" +{ + procedure SumCustomerBalances() Total: Decimal + var + Customer: Record Customer; + begin + if Customer.FindSet() then + repeat + Total += Customer."Balance (LCY)"; + until Customer.Next() = 0; + end; + + procedure GetFirstUSCustomer(var Customer: Record Customer): Boolean + begin + Customer.SetRange("Country/Region Code", 'US'); + exit(Customer.FindFirst()); + end; +} diff --git a/microsoft/knowledge/performance/pair-findset-with-next-loop.md b/microsoft/knowledge/performance/pair-findset-with-next-loop.md new file mode 100644 index 0000000..80f855c --- /dev/null +++ b/microsoft/knowledge/performance/pair-findset-with-next-loop.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [findset, findfirst, findlast, get, next, repeat-until, aa0181, aa0233] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use FindSet with repeat..Next; do not pair FindFirst/FindLast/Get with Next + +## Description + +Two CodeCop rules carve out the loop pattern. AA0181 says `FindSet()`/`Find()` "must be used with `Next()` method" — these are the multi-row APIs that the runtime sets up for forward iteration. AA0233 says do "NOT use `FindFirst()`/`FindLast()`/`Get()` with `Next()`" — these are single-row APIs, and iterating from them "wastes CPU and bandwidth." Both rules together define one boundary: choose `FindSet` when the body iterates; choose `FindFirst`, `FindLast`, or `Get` when the body uses exactly one record. + +## Best Practice + +When the body executes `repeat ... until Next() = 0;`, open the iteration with `FindSet()`. When the body needs one record and does not call `Next`, use `FindFirst`, `FindLast`, or — if the full primary key is known — `Get` (see `use-get-instead-of-findfirst-on-full-primary-key.md`). The choice is per call site, not a global preference. + +See sample: `pair-findset-with-next-loop.good.al`. + +## Anti Pattern + +`if Customer.FindFirst() then repeat ... until Customer.Next() = 0;` — AA0233 flags this. The single-row API does not prepare the runtime for iteration, so the loop pays a cost the FindSet path does not. The mirror anti-pattern is calling `FindSet` to read a single record (see `use-isempty-for-existence-check.md` when only existence is required). + +See sample: `pair-findset-with-next-loop.bad.al`. diff --git a/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.good.al b/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.good.al new file mode 100644 index 0000000..3bcdd9a --- /dev/null +++ b/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.good.al @@ -0,0 +1,21 @@ +codeunit 50240 "Perf Sample Trigger Param Good" +{ + procedure BulkFlagOrders(var SalesHeader: Record "Sales Header") + begin + if SalesHeader.FindSet(true) then + repeat + SalesHeader."Job Queue Status" := SalesHeader."Job Queue Status"::"Scheduled for Posting"; + // Trigger has nothing to add for a status flip in this code path. + SalesHeader.Modify(false); + until SalesHeader.Next() = 0; + end; + + procedure CreateOrder(var SalesHeader: Record "Sales Header"; CustomerNo: Code[20]) + begin + SalesHeader.Init(); + SalesHeader."Document Type" := SalesHeader."Document Type"::Order; + SalesHeader."Sell-to Customer No." := CustomerNo; + // OnInsert allocates the No.-Series number — the trigger is required. + SalesHeader.Insert(true); + end; +} diff --git a/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md b/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md new file mode 100644 index 0000000..45214b8 --- /dev/null +++ b/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [insert, modify, delete, trigger, run-trigger, write-parameters] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pass false to Insert/Modify/Delete when the table triggers do not need to fire + +## Description + +`Insert(true)`, `Modify(true)`, and `Delete(true)` run the table's `OnInsert`/`OnModify`/`OnDelete` trigger; the `(false)` form skips it. Per the upstream guidance, the trigger form should be used "only when needed" — every row whose write fires a trigger pays that cost, even when the trigger has nothing useful to add for the current call site. For tight bulk write paths the difference compounds linearly with row count. + +## Best Practice + +Reach for the `(false)` form when the calling code already enforces the invariants the trigger would, or when the trigger is empty for the current table/extension. Use `(true)` when the trigger does work the caller depends on (number-series allocation, validation, cascading writes). Decide per call, not by code style: a default of "always `true`" makes bulk writes pay for triggers they did not need, and a default of "always `false`" silently skips validation the trigger was put there to enforce. + +See sample: `pass-false-to-insert-when-trigger-not-needed.good.al`. + +## Anti Pattern + +Looping over thousands of rows and calling `Modify(true)` on each, when the table's `OnModify` trigger does nothing relevant for the operation. The trigger cost is paid per row; the user-visible behavior is identical to the `(false)` form. The mirror is using `(false)` for an operation that depends on trigger-side defaulting and silently producing rows that fail downstream validation. diff --git a/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md b/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md new file mode 100644 index 0000000..458d098 --- /dev/null +++ b/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [dictionary, temporary-table, lookup, o-of-1, key-lookup] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefer a Dictionary over a temporary table for pure lookups + +## Description + +A temporary table supports a full record API — filters, iteration, multi-field keys — but a pure key→value lookup pays for plumbing it does not use. Per the upstream guidance, "if a temporary table record is ONLY used as a lookup table, it is faster to use a dictionary which supports O(1) lookups instead of O(lg n) for temporary tables." The Dictionary type has no record machinery to traverse; the key hash answers the lookup directly. + +## Best Practice + +When the use of a temp record is "set a key, see if the row exists, read a single value", switch to `Dictionary of [Key, Value]`. Use the temp-table form when the use genuinely needs filtering, iteration in a specific order, or a multi-field key. Compatibility with code that expects a `Record` parameter is a real reason to keep the temp table; performance alone, on a pure lookup, is not. + +## Anti Pattern + +A temp `Record` declared, populated row by row, then queried with `SetRange(KeyField, X); if Find('=') then Value := Rec.ValueField;`. The lookup hashes the key behind the scenes and does the same work a `Dictionary` would, plus the per-row record overhead. The pattern often appears because the author originally needed iteration and the iteration was later removed without revisiting the data structure. diff --git a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.bad.al b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.bad.al deleted file mode 100644 index cf0ed96..0000000 --- a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.bad.al +++ /dev/null @@ -1,20 +0,0 @@ -codeunit 50135 "Perf Sample RecordRef Bad" -{ - procedure BlockCustomer(CustomerNo: Code[20]) - var - RecRef: RecordRef; - PkRef: KeyRef; - NoRef: FieldRef; - BlockedRef: FieldRef; - begin - RecRef.Open(Database::Customer); - PkRef := RecRef.KeyIndex(1); - NoRef := PkRef.FieldIndex(1); - NoRef.SetRange(CustomerNo); - if not RecRef.FindFirst() then - exit; - BlockedRef := RecRef.Field(54); - BlockedRef.Value(2); - RecRef.Modify(true); - end; -} diff --git a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.good.al b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.good.al deleted file mode 100644 index 44ed151..0000000 --- a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50134 "Perf Sample RecordRef Good" -{ - procedure BlockCustomer(CustomerNo: Code[20]) - var - Customer: Record Customer; - begin - if not Customer.Get(CustomerNo) then - exit; - Customer.Blocked := Customer.Blocked::All; - Customer.Modify(true); - end; -} diff --git a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md deleted file mode 100644 index 00ca392..0000000 --- a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [recordref, fieldref, dynamic, reflection] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Prefer direct record access over RecordRef where possible - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -RecordRef and FieldRef are the platform's reflection API: they work across tables the compiler does not know at authoring time. That flexibility costs per-operation overhead — every field access goes through a lookup — and loses compile-time type checking. For operations where the table is known, a strongly-typed Record variable is simpler and faster. - -## Best Practice - -Use Record variables for code paths that target a known table. Reach for RecordRef and FieldRef only when the table is genuinely dynamic (generic export/import, field-agnostic utilities, cross-table integrations). - -Only flag RecordRef usage as a performance concern when it appears inside a **hot, unbounded loop** — typically iterating over ledger-entry-scale tables (10,000+ rows) — where a strongly-typed Record alternative exists. RecordRef in bounded contexts, one-off operations, admin tools, setup helpers, or wizard code is not a performance concern and should not be flagged. - -See sample: `prefer-direct-record-over-recordref.good.al`. - -## Anti Pattern - -Using RecordRef as a habit, even when the target table is hardcoded two lines earlier, costs performance and hides intent from reviewers. - -See sample: `prefer-direct-record-over-recordref.bad.al`. - diff --git a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.good.al b/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.good.al deleted file mode 100644 index a370f7d..0000000 --- a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50130 "Perf Sample GetVsFind Good" -{ - procedure CustomerName(CustomerNo: Code[20]): Text[100] - var - Customer: Record Customer; - begin - if Customer.Get(CustomerNo) then - exit(Customer.Name); - end; -} diff --git a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md b/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md deleted file mode 100644 index e7d4d88..0000000 --- a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [get, findfirst, primary-key, lookup] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Prefer Get for primary-key lookups - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Get is a direct primary-key lookup: one index seek, one row, done. FindFirst with SetRange on the primary key fields reaches the same row through a more general code path and carries the overhead of filter setup and a broader optimizer decision. - -## Best Practice - -When the complete primary key is known, call Get. Use FindFirst only for non-primary-key lookups or when the filter is a partial prefix of the key. - -See sample: `prefer-get-for-primary-key-lookups.good.al`. - -## Anti Pattern - -Setting one SetRange per primary-key field and then calling FindFirst reproduces Get with more typing and slightly worse performance. - -See sample: `prefer-get-for-primary-key-lookups.bad.al`. - diff --git a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al new file mode 100644 index 0000000..0b8c21d --- /dev/null +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al @@ -0,0 +1,15 @@ +codeunit 50243 "Perf Sample ModifyAll Bad" +{ + procedure ApplyPriceUpdate(NewPrice: Decimal) + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetRange(Type, SalesLine.Type::Item); + // N writes when one ModifyAll would do. + if SalesLine.FindSet() then + repeat + SalesLine.Validate("Unit Price", NewPrice); + SalesLine.Modify(true); + until SalesLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.bad.al b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al similarity index 50% rename from microsoft/knowledge/performance/combine-multiple-modifyall-calls.bad.al rename to microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al index 3a9e190..c33d9c0 100644 --- a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.bad.al +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al @@ -1,12 +1,19 @@ -codeunit 51207 "Perf Sample CombineMA Bad" +codeunit 50242 "Perf Sample ModifyAll Good" { - procedure UpdateTolerance(DocumentNo: Code[20]; ToleranceAmount: Decimal) + procedure ApplyPriceUpdate(NewPrice: Decimal) + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetRange(Type, SalesLine.Type::Item); + SalesLine.ModifyAll("Unit Price", NewPrice); + end; + + procedure ApplyTolerance(DocumentNo: Code[20]; ToleranceAmount: Decimal) var CustLedgerEntry: Record "Cust. Ledger Entry"; begin CustLedgerEntry.SetRange("Document No.", DocumentNo); CustLedgerEntry.SetRange(Open, true); - // Two scans over the same filtered rows on a 10M-row ledger table. CustLedgerEntry.ModifyAll("Accepted Payment Tolerance", ToleranceAmount); CustLedgerEntry.ModifyAll("Accepted Pmt. Disc. Tolerance", false); end; diff --git a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md new file mode 100644 index 0000000..73a095c --- /dev/null +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [modifyall, deleteall, bulk, loop, modify, set-based] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use ModifyAll / DeleteAll instead of per-row Modify / Delete in a loop + +## Description + +`ModifyAll` and `DeleteAll` are the bulk APIs. Per the upstream guidance, they "execute as single SQL statements" when the table supports it — one round-trip updates or deletes every row in the filtered set. The anti-pattern is the loop equivalent: `FindSet` followed by per-row `Modify`/`Delete`, where the runtime issues one write per row. On a production-scale table the difference is the difference between a single statement and N statements. + +## Best Practice + +When the loop body does nothing more than assign a constant value (or a value computed once) to one or more fields, replace the loop with `ModifyAll("Field 1", Value1)` — and chain additional `ModifyAll` calls for additional fields. The same shape applies to `DeleteAll`. Be aware that the bulk APIs can regress to row-by-row execution for tables with certain trigger or media-field configurations (see `triggers-and-media-field-regress-modifyall.md`); when that regression applies, multiple `ModifyAll` calls become more expensive than one manual loop, so the choice is conditional, not absolute. + +See sample: `prefer-modifyall-over-per-row-modify.good.al`. + +## Anti Pattern + +`if SalesLine.FindSet() then repeat SalesLine.Validate("Unit Price", NewPrice); SalesLine.Modify(true); until SalesLine.Next() = 0;` — N writes when one would do. The pattern is easy to introduce when the loop initially does per-row computation and is later simplified to assign a constant; the loop scaffolding survives the simplification. + +See sample: `prefer-modifyall-over-per-row-modify.bad.al`. diff --git a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al new file mode 100644 index 0000000..c23886c --- /dev/null +++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al @@ -0,0 +1,13 @@ +codeunit 50233 "Perf Sample ReadIso Bad" +{ + procedure GetOrCreate(var AgentStatus: Record "Agent Status") + begin + // LockTable poisons every subsequent read of Agent Status in the + // surrounding transaction with UPDLOCK — even for callers that only read. + AgentStatus.LockTable(); + if not AgentStatus.Get() then begin + AgentStatus.Init(); + AgentStatus.Insert(); + end; + end; +} diff --git a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al new file mode 100644 index 0000000..be5cd55 --- /dev/null +++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al @@ -0,0 +1,11 @@ +codeunit 50232 "Perf Sample ReadIso Good" +{ + procedure GetOrCreate(var AgentStatus: Record "Agent Status") + begin + AgentStatus.ReadIsolation := IsolationLevel::ReadCommitted; + if not AgentStatus.Get() then begin + AgentStatus.Init(); + AgentStatus.Insert(); + end; + end; +} diff --git a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md new file mode 100644 index 0000000..c2f888b --- /dev/null +++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [readisolation, locktable, updlock, read-only, transaction-scope, isolation-level] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefer ReadIsolation over LockTable for read-only scenarios + +## Description + +`LockTable` and `ReadIsolation` solve different problems with different blast radii. Per the upstream guidance, "`LockTable` ensures that all READS against that table will happen with UPDLOCK for the remainder of the transaction." `ReadIsolation` "only pertains to the current record instance, while `LockTable` affects the lockstate of the entire transaction." `ReadIsolation` is also more expressive: it can heighten or lower the isolation level inside an already-established transaction. Reaching for `LockTable` when only a single read needs guarding therefore poisons every later read on that table — including reads in other code paths that share the transaction. + +## Best Practice + +For a read-only operation, or a single read that needs a higher isolation level than the surrounding transaction, set `Rec.ReadIsolation := IsolationLevel::ReadCommitted;` (or the level the call requires) immediately before the read. The hint applies only to that record instance. Save `LockTable` for code that genuinely needs every subsequent read on the table to acquire an update lock (see `findset-true-applies-updlock-on-read.md` for the alternative narrower mechanism on iterated reads). + +See sample: `prefer-readisolation-over-locktable-for-reads.good.al`. + +## Anti Pattern + +`Rec.LockTable();` at the top of a helper that only reads, perhaps to "make sure the read is consistent". Every subsequent read on that table for the rest of the transaction acquires `UPDLOCK`, including reads from unrelated code paths fused into the same transaction. The contention surfaces in unrelated user sessions, not in the helper that introduced it. + +See sample: `prefer-readisolation-over-locktable-for-reads.bad.al`. diff --git a/microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md b/microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md new file mode 100644 index 0000000..f290448 --- /dev/null +++ b/microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [table-size, hot-table, ledger-entry, item, customer, sales-line, scale] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Production-scale tables warrant concrete performance analysis + +## Description + +Some Business Central tables routinely reach sizes where access patterns matter much more than they do on a generic table. The upstream review guidance lists ten of them with P95 row counts: Item (~800k), Customer (~800k), Item Ledger Entry (~10M), Value Entry (~10M), G/L Entry (~10M), VAT Entry (~10M), Customer Ledger Entry (~10M), Vendor Ledger Entry (~10M), Sales Invoice Header (~300k), and Sales Invoice Line (~3M). These figures are not platform constants — they are the volumes a reviewer should assume when judging a change. + +## Best Practice + +For any code change that touches one of these tables, do not approve the pattern on intuition. Walk through the SQL the change implies (one query? one per row? one per chunk?), the memory it allocates (a `List` per row?), and the CPU work per row, against the row counts above. Smaller tables can tolerate a sub-optimal access pattern; these cannot. The rest of this domain — `apply-filters-before-iterating.md`, `use-setloadfields-for-partial-records.md`, `avoid-calcfields-in-loops.md`, `pair-findset-with-next-loop.md`, `avoid-get-inside-loop-on-persistent-tables.md` — exists primarily so that code touching these tables stays on the safe side of each rule. + +## Anti Pattern + +Generalizing from a unit test or a development tenant. A `FindSet` loop with a per-row `CalcFields` may execute in milliseconds against a few thousand rows on a developer's machine and become a multi-minute table scan against ten million Value Entry rows in production. Reasoning about performance from the dev-tenant timing instead of the production volume is the single most common way a regression ships. diff --git a/microsoft/knowledge/performance/query-objects-bypass-primary-key-cache.md b/microsoft/knowledge/performance/query-objects-bypass-primary-key-cache.md deleted file mode 100644 index 3c05629..0000000 --- a/microsoft/knowledge/performance/query-objects-bypass-primary-key-cache.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [query, cache, primary-key-cache, record-api, sql] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Query objects bypass the primary-key cache and always hit SQL - -## Description - -The Record API reuses a server-side primary-key cache: repeated reads of the same rows within a session or request can be served from memory without going to SQL. Query objects do not participate in that cache. Every execution of a query goes to the database, even when the same rows were just read through a Record variable in the same transaction. - -This inverts the usual intuition that queries are always faster than record loops. Queries win when they exploit a covering index, aggregate, or join multiple tables in SQL that AL would otherwise loop. They lose when the data is small, already cached, or read repeatedly in a short window — the per-call SQL round-trip dominates. - -Query objects also cannot write, cannot be backed by a page, and do not see the records a temp-table-backed AL flow has inserted but not committed. Choose them for set-based reads over indexed data, not as a generic replacement for the Record API. - -## Best Practice - -Use a query object when the shape of the work is genuinely set-based: aggregation, multi-table join, or a large read that benefits from a covering index. For hot single-record or small-result reads — especially lookups that will repeat in the same request — prefer the Record API so the primary-key cache does its job. - -## Anti Pattern - -Replacing a `Get` or a short filtered `FindSet` inside a frequently-called helper with a query object "for performance". Every caller now pays a SQL round-trip that the Record API cache had been absorbing, and the helper gets slower under load, not faster. diff --git a/microsoft/knowledge/performance/set-current-key-to-match-filters.good.al b/microsoft/knowledge/performance/set-current-key-to-match-filters.good.al deleted file mode 100644 index a891f40..0000000 --- a/microsoft/knowledge/performance/set-current-key-to-match-filters.good.al +++ /dev/null @@ -1,9 +0,0 @@ -codeunit 50122 "Perf Sample SetCurrentKey Good" -{ - procedure LinesForDocument(DocumentType: Enum "Sales Document Type"; DocumentNo: Code[20]; var SalesLine: Record "Sales Line") - begin - SalesLine.SetCurrentKey("Document Type", "Document No.", "Line No."); - SalesLine.SetRange("Document Type", DocumentType); - SalesLine.SetRange("Document No.", DocumentNo); - end; -} diff --git a/microsoft/knowledge/performance/set-current-key-to-match-filters.md b/microsoft/knowledge/performance/set-current-key-to-match-filters.md deleted file mode 100644 index 95effd7..0000000 --- a/microsoft/knowledge/performance/set-current-key-to-match-filters.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [setcurrentkey, key, index, sort, filter] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Set the current key to match your filters - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -AL chooses a key for a Find call based on the current SetCurrentKey selection. When filters do not align with any key, the platform either scans or falls back to a less selective index. On tables with production-scale row counts, this is the difference between an index seek and a table scan. - -## Best Practice - -Call SetCurrentKey with the fields you filter and sort on, in the order they appear in a table key. If no suitable key exists, add one via a table extension rather than relying on an unsupported filter pattern. - -See sample: `set-current-key-to-match-filters.good.al`. - -## Anti Pattern - -Setting many filters on fields that no key covers, and leaving the key selection to the platform's heuristics, produces non-deterministic performance that degrades as the table grows. - diff --git a/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al b/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al new file mode 100644 index 0000000..4ab3b0a --- /dev/null +++ b/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al @@ -0,0 +1,15 @@ +codeunit 50230 "Perf Sample SetCurrentKey Good" +{ + procedure ProcessLines(var SalesHeader: Record "Sales Header") + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetCurrentKey("Document Type", "Document No.", "Line No."); + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + if SalesLine.FindSet() then + repeat + // ... + until SalesLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md b/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md new file mode 100644 index 0000000..f7f8180 --- /dev/null +++ b/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [setcurrentkey, key, index, filter, sort] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pick a key whose fields cover the filter and sort with SetCurrentKey + +## Description + +The platform chooses a key for each record access. When the filters or required sort do not match the primary key — or any non-explicit choice — the query may run against a key that does not cover the filter columns. Per the upstream guidance, "Use `SetCurrentKey()` to select the most efficient key for your filters" and "match key fields to your filter/sort requirements." Filtering on fields that are not in any key is flagged as bad — there is no index to ride and the access ends up reading more than necessary. + +## Best Practice + +When the access pattern is anything other than primary-key lookup, look at the filters and the desired sort, then either pick an existing key whose leading fields cover them and call `SetCurrentKey(...)`, or declare a new key on the table for the pattern. Match leading fields first — a key starting with `"Document Type", "Document No.", "Line No."` serves a filter on those three; a key starting with `"Line No."` does not. + +See sample: `setcurrentkey-aligns-key-with-filters.good.al`. + +## Anti Pattern + +Applying filters on fields that no key indexes, leaving the platform to read more than it should. The query produces the right answer; the cost surfaces only at production volume. The mirror case is forgetting `SetCurrentKey` when the wanted sort differs from the primary key — the iteration may then be sorted in memory after a wider read than necessary. diff --git a/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md b/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md new file mode 100644 index 0000000..6b32de1 --- /dev/null +++ b/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [singleton, setup-table, sales-receivables-setup, general-ledger-setup, setloadfields, bounded] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Singleton setup tables hold one row; access-pattern optimization is wasted + +## Description + +Business Central setup tables — `Sales & Receivables Setup`, `General Ledger Setup`, `FA Setup`, `Purchases & Payables Setup`, and the broader pattern of any `*Setup` table — hold at most one record per company. Per the upstream guidance, "any access pattern is fine, no `SetLoadFields` needed" on these tables. The same applies to other small bounded tables (enum mappings, permission objects, Role IDs) and system metadata tables (`TableMetadata`, `Field`, `AllObjWithCaption`) where iteration is safe. + +## Best Practice + +Skip access-pattern optimization on singleton-setup-style tables. `SalesReceivablesSetup.Get()` does not need `SetLoadFields` (see `use-setloadfields-for-partial-records.md`); a `repeat ... until` over a permission-object table does not need bulk operations. Spend the review attention on the production-scale tables instead (see `production-scale-tables-warrant-extra-analysis.md`). + +## Anti Pattern + +Mechanically applying the rules in this domain to every `Record` variable in the codebase. Flagging "missing `SetLoadFields`" on `GeneralLedgerSetup` or "use `IsEmpty` instead of `FindSet`" on a setup table adds noise without payoff — the optimization saves nothing measurable on a one-row table — and trains readers to ignore the review channel. diff --git a/microsoft/knowledge/performance/skip-setloadfields-on-narrow-tables-and-short-loops.md b/microsoft/knowledge/performance/skip-setloadfields-on-narrow-tables-and-short-loops.md deleted file mode 100644 index 33a299e..0000000 --- a/microsoft/knowledge/performance/skip-setloadfields-on-narrow-tables-and-short-loops.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [setloadfields, heuristics, narrow-table, short-loop, diminishing-returns] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# SetLoadFields pays off at scale; skip it on narrow tables and short loops - -## Description - -`SetLoadFields` reduces the number of columns the platform hydrates per record. It delivers real savings on wide tables with blob, media, or many text fields when the iteration touches a small subset. Below certain thresholds the accounting flips the other way: narrow tables (fewer than ~10 fields) save almost nothing per row, and short loops (fewer than ~10 iterations) amortize the narrowing over too few fetches to outweigh the extra code and the specification-and-access-set coupling that future edits have to maintain. Recommending SetLoadFields on every Find/Get call produces low-value churn and invites the opposite mistake — listing a field in SetLoadFields and then forgetting to access it, which triggers a second round-trip to load the missing field. - -## Best Practice - -Reach for SetLoadFields when the table is wide (10+ fields, especially with blobs) AND the code path reads a small subset AND the iteration or fetch count is material. When in doubt on a short loop over a narrow table, leave SetLoadFields out; the complexity cost is not earned. The filter-only-field rule from `omit-filter-only-fields-from-setloadfields` still applies: fields used only in filters stay out of the list. - -## Anti Pattern - -A 5-row loop over a 6-field setup table prefaced by `Rec.SetLoadFields(...)`. The author has added two lines of code, coupled the loop to a field specification that needs to be updated on every schema change, and saved nanoseconds. The same pattern applied mechanically to every Find call in a codebase produces hundreds of diffs that do not move the performance needle. diff --git a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.bad.al b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.bad.al deleted file mode 100644 index cb38dbf..0000000 --- a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 51205 "Perf Sample LockTable Bad" -{ - procedure GetOrCreate(var AgentStatus: Record "Integer"): Boolean - begin - // Every caller takes an exclusive lock, even the ones that only read. - // Under load the helper becomes the dominant contention point. - AgentStatus.LockTable(); - if not AgentStatus.Get(1) then begin - AgentStatus.Number := 1; - AgentStatus.Insert(); - end; - exit(true); - end; -} diff --git a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al deleted file mode 100644 index 1eca2d9..0000000 --- a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al +++ /dev/null @@ -1,18 +0,0 @@ -codeunit 51204 "Perf Sample LockTable Good" -{ - procedure GetOrCreate(var AgentStatus: Record "Integer"): Boolean - begin - // Read path: consistent read on this record instance only. - AgentStatus.ReadIsolation := IsolationLevel::ReadCommitted; - if AgentStatus.Get(1) then - exit(true); - - // Write path: lock only when we are about to insert. - AgentStatus.LockTable(); - if not AgentStatus.Get(1) then begin - AgentStatus.Number := 1; - AgentStatus.Insert(); - end; - exit(true); - end; -} diff --git a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md deleted file mode 100644 index f9dae60..0000000 --- a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [locktable, read-only, write-path, contention, helper] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Split read-only and write paths so LockTable runs only when needed - -## Description - -LockTable causes reads against the table to use update locks for the remainder of the transaction. In a helper that is called from many read-only sites and a few write sites, placing LockTable unconditionally at the top serializes every reader on every other reader's lock — the helper becomes a system-wide contention point. The correct shape is a conditional structure: try the read-only path first, and only fall through to LockTable when the code genuinely needs to modify the table. - -## Best Practice - -For paths that are read-only, prefer `ReadIsolation` over `LockTable`. Setting `Rec.ReadIsolation := IsolationLevel::ReadCommitted` on a record variable gives fine-grained, per-instance control over the isolation level without taking an update lock on the table for the rest of the transaction. Use `ReadCommitted` as the normal read-only choice; move to `RepeatableRead`, `Serializable`, or an update lock only when the code has a concrete consistency invariant that requires it. Use `LockTable` only for paths that genuinely write to the table. - -For helpers that may or may not modify records, factor the code so readers return immediately without a lock and only writers reach the LockTable call. A common pattern: attempt `Rec.Get()` first; if it returns the row, exit with the value; otherwise LockTable and proceed with the Insert. Document the pattern in a comment on the helper so callers understand why the LockTable is inside a branch. - -See sample: `split-read-only-and-write-paths-to-avoid-locktable.good.al`. - -## Anti Pattern - -A `GetOrCreate` helper that unconditionally calls `Rec.LockTable()` at the top, then Gets the row, then returns it. Every reader now blocks every other reader even though none of them intend to write. Under load the helper becomes the dominant bottleneck. - -See sample: `split-read-only-and-write-paths-to-avoid-locktable.bad.al`. diff --git a/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md b/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md deleted file mode 100644 index 21ce82d..0000000 --- a/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [event, subscriber, modifyall, deleteall, bulk, row-by-row] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Table event subscribers force ModifyAll and DeleteAll to run row-by-row - -## Description - -`ModifyAll` and `DeleteAll` normally compile to a single set-based SQL UPDATE or DELETE. That optimization is conditional: the server falls back to row-by-row execution when it must invoke AL per affected row. Common causes are global table delete triggers, table modify/delete event subscribers, and Media or MediaSet fields added to the table or a table extension. - -The slowdown is invisible in the caller's source: the call site still reads as a bulk operation. It only shows up under load, and adding an apparently cheap subscriber (even an empty one, or one that guards on a condition and returns) is enough to trigger the fallback for every caller of ModifyAll/DeleteAll on that table across the system. Central tables — Item Ledger Entry, G/L Entry, Sales Line — are the worst places to attach such subscribers because every extension's bulk operation pays the cost. - -## Best Practice - -Before subscribing to a table's modify or delete events, consider whether the logic can live elsewhere — on the triggering action, on a specific OnValidate, or on a business-event publisher. If the subscriber, global trigger, or Media/MediaSet field is unavoidable, document that the table may no longer support set-based ModifyAll/DeleteAll. When a table has not regressed, prefer a small number of ModifyAll/DeleteAll calls; they are still commonly 10-50x faster than a manual loop. - -## Anti Pattern - -An empty or nearly-empty `OnAfterModifyEvent` subscriber on `Sales Line` added as a placeholder for future integration. Every `ModifyAll` on `Sales Line` — in the base app, in every extension, in every tenant — can now run one SQL UPDATE per row. The same regression can come from a global delete trigger or from adding a Media field to the table. diff --git a/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md new file mode 100644 index 0000000..d799b5f --- /dev/null +++ b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [temporary-table, in-memory, findset, findfirst, get, no-db-cost] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Temporary tables are in-memory; access-pattern rules do not apply + +## Description + +A record declared `Temporary` (or a page with `SourceTableTemporary = true`) lives entirely in memory; reads and writes never reach SQL. Per the upstream guidance, "any access pattern (FindSet, FindFirst, Get, loops) on temp tables is acceptable — they are in-memory and fast." The rules in the rest of this domain — partial loading, bulk operations, N+1 detection, `IsEmpty` over `Count` — exist to avoid database round-trips that a temporary table does not perform. + +## Best Practice + +Recognize the `Temporary` property (on a record variable, table declaration, or page's `SourceTableTemporary`) and exempt the code from access-pattern flags. The `SetLoadFields`/`FindSet` discipline that matters for `Customer` does not matter for a temporary `Customer` variable used as a working set. The interesting performance question on a temp table is volume in memory, not query plan. + +## Anti Pattern + +Flagging a temporary table's `FindFirst` inside a loop, or a temporary table without `SetLoadFields`, as a performance issue. The recommendation produces no measurable gain and obscures genuine issues elsewhere in the same review. diff --git a/microsoft/knowledge/performance/treat-ledger-entry-tables-as-production-scale.md b/microsoft/knowledge/performance/treat-ledger-entry-tables-as-production-scale.md deleted file mode 100644 index cd38037..0000000 --- a/microsoft/knowledge/performance/treat-ledger-entry-tables-as-production-scale.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [ledger-entry, production-scale, hot-table, item-ledger, gl-entry, sales-invoice-line] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Treat ledger-entry and line-type tables as production-scale when reviewing performance - -## Description - -A handful of Business Central tables grow to millions of rows in production tenants: Item Ledger Entry, Value Entry, G/L Entry, VAT Entry, Customer Ledger Entry, Vendor Ledger Entry, Sales Invoice Line, Purchase Invoice Line, Detailed Cust. Ledg. Entry, Detailed Vendor Ledg. Entry, and equivalent line-type tables. Master-data tables like Customer, Vendor, and Item typically reach the high hundreds of thousands. A performance review that treats these tables with the same latitude as setup tables or small reference lists under-reports real regressions; the same filter-or-key mistake that is invisible on a 50-row table is a full table scan over millions of rows on these. - -## Best Practice - -When a code change touches any of the above tables, demand concrete performance reasoning before accepting it: an appropriate key selection, a SetLoadFields narrowing, filters that use the key prefix, no N+1 inside the iteration. A finding on one of these tables should almost never be downgraded from High to Low on the grounds that "the operation looks small" — at production scale the operation is never small. - -## Anti Pattern - -Applying review heuristics uniformly to all tables. A missing SetCurrentKey on a Setup table changes nothing; the same mistake on Item Ledger Entry turns a list page into a multi-second load. The asymmetry is the whole point of the catalog — knowing which tables warrant the stricter read. diff --git a/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md b/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md new file mode 100644 index 0000000..c4890a0 --- /dev/null +++ b/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [modifyall, deleteall, regression, triggers, media, getglobaltabletriggermask, subscriber] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Triggers, subscribers, and media fields can silently regress ModifyAll / DeleteAll + +## Description + +`ModifyAll` and `DeleteAll` usually execute as single SQL statements, but the platform falls back to a fetch-then-row-by-row loop under specific conditions. Per the upstream guidance, the regression is triggered by any of: global database triggers defined via `GetGlobalTableTriggerMask` or `GetDatabaseTableTriggerSetup` (so that `OnDatabaseDelete`/`OnGlobalDelete` must run); event subscribers on the table's `OnBeforeDelete`/`OnAfterDelete` (for `DeleteAll`) or `OnBeforeModify`/`OnAfterModify` (for `ModifyAll`); or "adding a Media or MediaSet table field to either the table or table extension." Each of these forces the platform to materialize each affected row in AL. + +## Best Practice + +Before introducing any of the above on a table — a global trigger registration, a `Modify`/`Delete` subscriber, a media or media-set field — note every `ModifyAll`/`DeleteAll` that targets the table and assess whether the regression cost is acceptable. The upstream guidance is explicit: "There should be a very good reason for doing any of the above since they will significantly regress performance of `ModifyAll` and/or `DeleteAll`." Once a table has regressed, multiple `ModifyAll` calls each iterate the rows themselves, so consolidating to one explicit `FindSet`+`Modify` loop becomes faster than chaining several `ModifyAll` calls. + +## Anti Pattern + +Adding a media field to a hot table — or subscribing to its modify/delete events from a generic logging codeunit — without auditing the bulk-write call sites. The schema change is mechanical; the performance change is invisible at the call site and only surfaces when a previously fast `ModifyAll` starts paying the per-row trigger cost in production. The mirror anti-pattern is chaining several `ModifyAll` calls on a table that has already regressed; each one re-iterates the same rows. diff --git a/microsoft/knowledge/performance/uninstall-test-framework-to-measure-insert-performance.md b/microsoft/knowledge/performance/uninstall-test-framework-to-measure-insert-performance.md deleted file mode 100644 index ca4d81c..0000000 --- a/microsoft/knowledge/performance/uninstall-test-framework-to-measure-insert-performance.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [test-framework, bulk-insert, performance-test, benchmark, insert] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Uninstall the test framework to measure insert performance - -## Description - -Business Central's server uses a bulk insert optimization that batches multiple row inserts into a single SQL round-trip when conditions allow. When the test framework is installed on the environment, that optimization is disabled — inserts fall back to one SQL statement per row. The behavior is a side-effect of how the test framework instruments AL execution and applies whether or not any test is actually running. - -For functional tests this is invisible; for performance measurement it is catastrophic. A benchmark that inserts ten thousand rows with the test framework present reports a number that has nothing to do with production, because production will not run in row-by-row mode. Treating the measurement as a real baseline produces conclusions that are wrong by a large constant factor. - -The same caveat applies to Update and Delete paths where bulk optimizations exist — the test framework's presence suppresses them. - -## Best Practice - -Before running any insert, update, or delete throughput benchmark — whether via the Performance Toolkit, a hand-rolled harness, or `SessionInformation` assertions — uninstall the test framework from the target environment. Re-install it only for functional test runs. Document this step in the benchmark procedure so future measurements are comparable. - -## Anti Pattern - -A performance regression report comparing two builds on a sandbox that has the test framework installed. Both numbers are row-by-row timings; the ratio between them may be meaningful, but neither number reflects production, and any absolute throughput claim derived from the run is wrong. diff --git a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md b/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md deleted file mode 100644 index 3e14fdc..0000000 --- a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [report, addloadfields, ondatapreitem, layout, partial-record] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use AddLoadFields in report dataitems - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Reports iterate a dataitem's record automatically; the developer does not control the Find call directly. AddLoadFields, called in OnPreDataItem, tells the platform which fields the layout and the dataitem triggers will read. Without it the report streams every field of every row — for a ledger-entry dataitem on a production tenant, that is the dominant cost of the report. - -## Best Practice - -In each dataitem's OnPreDataItem trigger, call AddLoadFields for every field used by the layout, by the dataitem's triggers, and by any code that runs in the row-level event hooks. If the layout uses a FlowField, also ensure CalcFields is called and that the underlying key is loaded (see add-sift-keys-for-flowfields). - -See sample: `use-addloadfields-in-report-layouts.good.al`. - -## Anti Pattern - -Omitting AddLoadFields is the default for reports generated by the AL wizard. For a dataitem backed by a ledger-entry table, this silently turns the report into a full-column scan. - diff --git a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.bad.al b/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.bad.al deleted file mode 100644 index efdbdb0..0000000 --- a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50115 "Perf Sample CalcSums Bad" -{ - procedure OutstandingForCustomer(CustomerNo: Code[20]) Total: Decimal - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - CustLedgerEntry.SetRange("Customer No.", CustomerNo); - CustLedgerEntry.SetRange(Open, true); - if CustLedgerEntry.FindSet() then - repeat - Total += CustLedgerEntry."Remaining Amt. (LCY)"; - until CustLedgerEntry.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.good.al b/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.good.al deleted file mode 100644 index f825b1f..0000000 --- a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50114 "Perf Sample CalcSums Good" -{ - procedure OutstandingForCustomer(CustomerNo: Code[20]): Decimal - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - CustLedgerEntry.SetRange("Customer No.", CustomerNo); - CustLedgerEntry.SetRange(Open, true); - CustLedgerEntry.CalcSums("Remaining Amt. (LCY)"); - exit(CustLedgerEntry."Remaining Amt. (LCY)"); - end; -} diff --git a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md b/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md deleted file mode 100644 index 6cb4a55..0000000 --- a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [calcsums, sift, sum, aggregate, totals] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use CalcSums to aggregate filtered sets - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -When the task is to compute a sum over a filtered set, CalcSums lets the platform push the aggregation down to SQL using SIFT indexes. Iterating rows in AL to accumulate a total transports every row's data to the runtime only to discard it after adding one field. On ledger-entry-scale tables this difference is dramatic. The same SIFT infrastructure backs Sum-style FlowFields; when the value you need is already declared as a FlowField, calling CalcSums on the underlying table with the correct filters produces the same aggregate. - -## Best Practice - -Set the required filters on the record, then call CalcSums on the field you want aggregated. Ensure the table has a key whose SumIndexFields includes the summed field and whose key prefix matches the filters (see add-sift-keys-for-flowfields). - -See sample: `use-calcsums-for-flowfield-totals.good.al`. - -## Anti Pattern - -Looping a filtered set with FindSet and adding a field to an accumulator on every iteration performs work in AL that SQL already knows how to do in one aggregate query. - -See sample: `use-calcsums-for-flowfield-totals.bad.al`. - diff --git a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.bad.al b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.bad.al deleted file mode 100644 index 1155668..0000000 --- a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.bad.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 50934 "Perf Sample TempLookup Bad" -{ - procedure MarkSeenCustomers(var SalesLine: Record "Sales Line") - var - TempCustomer: Record Customer temporary; - begin - if SalesLine.FindSet() then - repeat - if not TempCustomer.Get(SalesLine."Sell-to Customer No.") then begin - TempCustomer.Init(); - TempCustomer."No." := SalesLine."Sell-to Customer No."; - TempCustomer.Insert(); - end; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.good.al b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.good.al deleted file mode 100644 index ff6d499..0000000 --- a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.good.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50933 "Perf Sample Dictionary Good" -{ - procedure MarkSeenCustomers(var SalesLine: Record "Sales Line") - var - SeenCustomerNos: Dictionary of [Code[20], Boolean]; - begin - if SalesLine.FindSet() then - repeat - if not SeenCustomerNos.ContainsKey(SalesLine."Sell-to Customer No.") then - SeenCustomerNos.Add(SalesLine."Sell-to Customer No.", true); - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.md b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.md deleted file mode 100644 index 0e2843c..0000000 --- a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [dictionary, temporary-table, lookup, identity, o1] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use Dictionary for temporary identity lookups - -## Description - -A temporary record is useful when code needs record semantics: filters, keys, FlowFields, or table-shaped buffers. When the only operation is "have I seen this key?" or "what value belongs to this key?", a `Dictionary` is the simpler and faster structure. Dictionary lookup is O(1) by key, while a temporary table still pays record and key-management overhead. - -## Best Practice - -Use `Dictionary` for in-memory lookup sets and maps whose keys fit in memory and whose access pattern is by identity. Keep temporary tables for data that needs table APIs, multiple keys, filter expressions, or later processing as records. - -See sample: `use-dictionary-for-temporary-identity-lookups.good.al`. - -## Anti Pattern - -Creating a temporary table solely to call `Get` or `FindFirst` by a single key in a loop. The code looks familiar to AL developers, but it is heavier than the lookup problem requires. - -See sample: `use-dictionary-for-temporary-identity-lookups.bad.al`. diff --git a/microsoft/knowledge/performance/use-findset-readonly-by-default.bad.al b/microsoft/knowledge/performance/use-findset-readonly-by-default.bad.al deleted file mode 100644 index efd0e49..0000000 --- a/microsoft/knowledge/performance/use-findset-readonly-by-default.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50109 "Perf Sample FindSetReadonly Bad" -{ - procedure SumInvoiceLines(var SalesLine: Record "Sales Line") Total: Decimal - begin - if SalesLine.FindSet(true) then - repeat - Total += SalesLine."Line Amount"; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-findset-readonly-by-default.good.al b/microsoft/knowledge/performance/use-findset-readonly-by-default.good.al deleted file mode 100644 index 371c5f6..0000000 --- a/microsoft/knowledge/performance/use-findset-readonly-by-default.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50108 "Perf Sample FindSetReadonly Good" -{ - procedure SumInvoiceLines(var SalesLine: Record "Sales Line") Total: Decimal - begin - if SalesLine.FindSet() then - repeat - Total += SalesLine."Line Amount"; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-findset-readonly-by-default.md b/microsoft/knowledge/performance/use-findset-readonly-by-default.md deleted file mode 100644 index 6843c91..0000000 --- a/microsoft/knowledge/performance/use-findset-readonly-by-default.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [findset, lock, locktable, readonly, update] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use FindSet in read-only mode by default - -## Description - -FindSet has two modes: FindSet() and FindSet(false) are read-only and take no update lock; FindSet(true) sets update-lock read isolation on the record before fetching. Update locks are expensive and hold for the lock scope, so passing `true` when you do not intend to modify the records increases contention under load. - -## Best Practice - -Call FindSet with no arguments when the loop only reads field values. Pass `true` only when the same loop is expected to call Modify, Delete, or Rename on the record, and the correctness of the operation depends on the matching rows being locked for the iteration. - -See sample: `use-findset-readonly-by-default.good.al`. - -## Anti Pattern - -Writing FindSet(true) reflexively for every iteration forces the platform to take a LockTable on every call, even when the loop only reads values. The older two-parameter signature `FindSet(ForUpdate, UpdateKey)` is obsolete and must not be used. - -See sample: `use-findset-readonly-by-default.bad.al`. - diff --git a/microsoft/knowledge/performance/use-findset-with-next.bad.al b/microsoft/knowledge/performance/use-findset-with-next.bad.al deleted file mode 100644 index 8467ce1..0000000 --- a/microsoft/knowledge/performance/use-findset-with-next.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50103 "Perf Sample FindSetWithNext Bad" -{ - procedure SumLineAmounts(var SalesLine: Record "Sales Line") Total: Decimal - begin - if SalesLine.FindFirst() then - repeat - Total += SalesLine."Line Amount"; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-findset-with-next.good.al b/microsoft/knowledge/performance/use-findset-with-next.good.al deleted file mode 100644 index 412c943..0000000 --- a/microsoft/knowledge/performance/use-findset-with-next.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50102 "Perf Sample FindSetWithNext Good" -{ - procedure SumLineAmounts(var SalesLine: Record "Sales Line") Total: Decimal - begin - if SalesLine.FindSet() then - repeat - Total += SalesLine."Line Amount"; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-findset-with-next.md b/microsoft/knowledge/performance/use-findset-with-next.md deleted file mode 100644 index c78c1aa..0000000 --- a/microsoft/knowledge/performance/use-findset-with-next.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [findset, next, repeat, iteration, aa0181] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use FindSet with Next for iteration - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -When iterating over a filtered set of records with repeat-until, use FindSet together with Next. CodeCop rule AA0181 requires FindSet or Find to be paired with Next; using FindFirst or FindLast as the loop starter misrepresents intent and leads to rule AA0233. - -## Best Practice - -Call FindSet to start the iteration and Next to advance. Guard the loop with the standard `if FindSet() then ... until Next() = 0` idiom so callers can still handle the empty-set case. - -See sample: `use-findset-with-next.good.al`. - -## Anti Pattern - -Starting a repeat-until loop with FindFirst or FindLast reads only one row and then calls Next on an iterator that was not intended for full-set traversal. The platform pays extra work to fetch the single row and the loop silhouette is misleading to reviewers. - -See sample: `use-findset-with-next.bad.al`. - diff --git a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.bad.al b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al similarity index 52% rename from microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.bad.al rename to microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al index 408c2e9..34f4ec3 100644 --- a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.bad.al +++ b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al @@ -1,11 +1,11 @@ -codeunit 50131 "Perf Sample GetVsFind Bad" +codeunit 50211 "Perf Sample GetByPK Bad" { - procedure CustomerName(CustomerNo: Code[20]): Text[100] + procedure ShowName(CustomerNo: Code[20]) var Customer: Record Customer; begin Customer.SetRange("No.", CustomerNo); if Customer.FindFirst() then - exit(Customer.Name); + Message(Customer.Name); end; } diff --git a/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al new file mode 100644 index 0000000..d625150 --- /dev/null +++ b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al @@ -0,0 +1,10 @@ +codeunit 50210 "Perf Sample GetByPK Good" +{ + procedure ShowName(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if Customer.Get(CustomerNo) then + Message(Customer.Name); + end; +} diff --git a/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md new file mode 100644 index 0000000..06c0383 --- /dev/null +++ b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [get, findfirst, primary-key, setrange, lookup] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use Get when the full primary key is known; FindFirst is the wrong tool + +## Description + +`Get(...)` is the direct primary-key lookup. `FindFirst()` walks an index — even when narrowed by `SetRange` on every primary-key field. The upstream review guidance treats `Customer.SetRange("No.", CustomerNo); if Customer.FindFirst() then ...` as a bad pattern and `if Customer.Get(CustomerNo) then ...` as the correction. The two reach the same record; only `Get` expresses the lookup as a primary-key seek. + +## Best Practice + +When all primary-key fields are available at the call site, call `Get` (or `GetBySystemId`) with them. Reserve `FindFirst` for cases where the filter is on something other than the full primary key — a unique secondary field, a partial composite key, a sort that the caller cares about. + +See sample: `use-get-instead-of-findfirst-on-full-primary-key.good.al`. + +## Anti Pattern + +Composing `SetRange` calls that exactly cover the primary key and then calling `FindFirst`. The result is correct but the call site reads as "search the table" rather than "look up by key", which obscures both the intent and the access pattern from later reviewers. + +See sample: `use-get-instead-of-findfirst-on-full-primary-key.bad.al`. diff --git a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.good.al b/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.good.al deleted file mode 100644 index cc3377a..0000000 --- a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50132 "Perf Sample InsertParam Good" -{ - procedure BulkLoadTempItems(var TempItem: Record Item temporary; Source: List of [Code[20]]) - var - ItemNo: Code[20]; - begin - foreach ItemNo in Source do begin - TempItem."No." := ItemNo; - TempItem.Insert(false); - end; - end; -} diff --git a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md b/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md deleted file mode 100644 index 708f53e..0000000 --- a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [insert, modify, delete, triggers, parameters] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Choose Insert, Modify, and Delete parameters deliberately - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Insert, Modify, and Delete accept a boolean that controls whether the table's OnInsert / OnModify / OnDelete trigger fires. Running the trigger for scratch or migrated data is often unnecessary work — side effects, posting rules, validations — for rows that were already validated upstream. Running the trigger when application logic depends on it is non-negotiable. - -## Best Practice - -Call Insert(true), Modify(true), or Delete(true) when the table's trigger logic is part of the operation's semantics. Call Insert(false), Modify(false), or Delete(false) when the operation is bulk data movement or temporary-table manipulation and the trigger would duplicate work or fire invalid side effects. - -See sample: `use-insert-false-when-skipping-triggers.good.al`. - -## Anti Pattern - -Blindly passing `true` everywhere pays for triggers on rows that do not need them. Blindly passing `false` silently skips validations that the table's author intended to be mandatory. - diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-check.bad.al b/microsoft/knowledge/performance/use-isempty-for-existence-check.bad.al new file mode 100644 index 0000000..1ab47e7 --- /dev/null +++ b/microsoft/knowledge/performance/use-isempty-for-existence-check.bad.al @@ -0,0 +1,17 @@ +codeunit 50213 "Perf Sample IsEmpty Bad" +{ + procedure HasOpenSalesOrders(CustomerNo: Code[20]): Boolean + var + SalesHeader: Record "Sales Header"; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); + // Count materializes a count the caller does not need. + if SalesHeader.Count() > 0 then + exit(true); + // FindFirst materializes a row the caller throws away. + if SalesHeader.FindFirst() then + exit(true); + exit(false); + end; +} diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-check.good.al b/microsoft/knowledge/performance/use-isempty-for-existence-check.good.al new file mode 100644 index 0000000..b1ea8d8 --- /dev/null +++ b/microsoft/knowledge/performance/use-isempty-for-existence-check.good.al @@ -0,0 +1,11 @@ +codeunit 50212 "Perf Sample IsEmpty Good" +{ + procedure HasOpenSalesOrders(CustomerNo: Code[20]): Boolean + var + SalesHeader: Record "Sales Header"; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); + exit(not SalesHeader.IsEmpty()); + end; +} diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-check.md b/microsoft/knowledge/performance/use-isempty-for-existence-check.md new file mode 100644 index 0000000..ab574ec --- /dev/null +++ b/microsoft/knowledge/performance/use-isempty-for-existence-check.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [isempty, count, findfirst, existence-check, exists] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use IsEmpty for existence checks, not Count() or FindFirst() + +## Description + +When the caller only needs to know whether any row matches a filter, `IsEmpty()` is the API designed for the question. Per the upstream guidance, "`IsEmpty()` is more efficient as it stops at first record found." `Count() > 0` materializes a count the caller does not need; `FindFirst()` materializes a row the caller does not need. Both do work that `IsEmpty` does not. + +## Best Practice + +Phrase existence checks as `if not Record.IsEmpty() then ...` (or `if Record.IsEmpty() then ...` for the negative). Apply filters via `SetRange`/`SetFilter` before the call so the existence check runs against the intended subset. Reserve `Count` for cases where the actual number matters and `FindFirst` for cases where the record fields are read. + +See sample: `use-isempty-for-existence-check.good.al`. + +## Anti Pattern + +`if Customer.Count() > 0 then ...` and `if Customer.FindFirst() then ...` (when the record is discarded) — both are flagged by the upstream guidance as the wrong tool. The first asks the database for the full count; the second asks for a row's fields. Both answers go unused. + +See sample: `use-isempty-for-existence-check.bad.al`. diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-checks.bad.al b/microsoft/knowledge/performance/use-isempty-for-existence-checks.bad.al deleted file mode 100644 index 8674d63..0000000 --- a/microsoft/knowledge/performance/use-isempty-for-existence-checks.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50121 "Perf Sample IsEmpty Bad" -{ - procedure HasOpenDocuments(CustomerNo: Code[20]): Boolean - var - SalesHeader: Record "Sales Header"; - begin - SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); - SalesHeader.SetRange(Status, SalesHeader.Status::Open); - exit(SalesHeader.Count() > 0); - end; -} diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-checks.good.al b/microsoft/knowledge/performance/use-isempty-for-existence-checks.good.al deleted file mode 100644 index 72cff56..0000000 --- a/microsoft/knowledge/performance/use-isempty-for-existence-checks.good.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50120 "Perf Sample IsEmpty Good" -{ - procedure HasOpenDocuments(CustomerNo: Code[20]): Boolean - var - SalesHeader: Record "Sales Header"; - begin - SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); - SalesHeader.SetRange(Status, SalesHeader.Status::Open); - exit(not SalesHeader.IsEmpty()); - end; -} diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-checks.md b/microsoft/knowledge/performance/use-isempty-for-existence-checks.md deleted file mode 100644 index 35955fc..0000000 --- a/microsoft/knowledge/performance/use-isempty-for-existence-checks.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [isempty, count, findfirst, existence] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use IsEmpty for existence checks - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -IsEmpty is the cheapest way to answer whether at least one row matches the current filters. It short-circuits at the first match and never hydrates a record. Count() scans and counts the entire set; FindFirst fetches a full row just to be discarded. - -## Best Practice - -Use `if not Rec.IsEmpty() then ...` for existence checks. Reserve Count for cases where the exact number of rows is needed, and FindFirst for cases where you actually want the row's field values. - -See sample: `use-isempty-for-existence-checks.good.al`. - -## Anti Pattern - -`if Rec.Count() > 0` iterates the whole set just to answer a yes/no question. `if Rec.FindFirst() then` loads an entire row of data the caller never reads. - -See sample: `use-isempty-for-existence-checks.bad.al`. - diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al index 7cf7fc9..c9b4ce2 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al @@ -1,14 +1,14 @@ -codeunit 50111 "Perf Sample SetLoadFields Bad" +codeunit 50219 "Perf Sample LoadFields Bad" { - procedure ExportItemNumbers(var Item: Record Item) + procedure ListUSCustomerNames() + var + Customer: Record Customer; begin - if Item.FindSet() then + // Loads every Customer column on every row, when only Name is read. + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then repeat - Export(Item."No.", Item.Description); - until Item.Next() = 0; - end; - - local procedure Export(ItemNo: Code[20]; Description: Text[100]) - begin + Message(Customer.Name); + until Customer.Next() = 0; end; } diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al index 990e3ee..772023c 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al @@ -1,15 +1,23 @@ -codeunit 50110 "Perf Sample SetLoadFields Good" +codeunit 50218 "Perf Sample LoadFields Good" { - procedure ExportItemNumbers(var Item: Record Item) + procedure ListUSCustomerNames() + var + Customer: Record Customer; begin - Item.SetLoadFields("No.", Description); - if Item.FindSet() then + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then repeat - Export(Item."No.", Item.Description); - until Item.Next() = 0; + Message(Customer.Name); + until Customer.Next() = 0; end; - local procedure Export(ItemNo: Code[20]; Description: Text[100]) + procedure LookupSkuPolicy(LocationCode: Code[10]) Policy: Enum "SKU Creation Method" + var + Location: Record Location; begin + Location.SetLoadFields("SKU Creation Policy"); + if Location.Get(LocationCode) then + Policy := Location."SKU Creation Policy"; end; } diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md index 3acf4f2..85d20b3 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -1,29 +1,26 @@ --- bc-version: [all] domain: performance -keywords: [setloadfields, partial-record, blob, bandwidth] +keywords: [setloadfields, partial-record, normal-field, flowfield, get, findset] technologies: [al] countries: [w1] application-area: [all] --- -# Use SetLoadFields for partial records +# Use SetLoadFields to load only the fields the code reads ## Description -SetLoadFields instructs the platform to hydrate only the listed fields on a record variable. On wide tables, or tables with BLOB or media fields, the difference is substantial: a Sales Invoice Line has dozens of fields and loading all of them for every row of a large set is wasted bandwidth. Primary key fields, SystemId, and system audit fields are always loaded automatically. SetLoadFields works only with FieldClass = Normal; FlowFields and FlowFilters cannot be partial-loaded. +`SetLoadFields(...)` declares the subset of normal fields the next read should materialize, "reducing data read and transfer thereby improving performance significantly." Per the upstream guidance, "the gains scale with the amount of rows read, so for loops that read many rows `SetLoadFields` is even more important." Primary-key fields, `SystemId`, and system audit fields are loaded automatically, "and fields that are filtered on are also automatically included" — those do not need to appear in the list. `SetLoadFields` only affects `FieldClass = Normal`; it does not narrow FlowFields or FlowFilters. ## Best Practice -Call SetLoadFields before FindSet, FindFirst, or Get when the table is wide enough to matter (roughly 10+ fields) and the code path reads a small subset (roughly under 60%) across a material number of rows. Short loops over narrow tables usually do not earn the extra coupling; see `skip-setloadfields-on-narrow-tables-and-short-loops` for that exception. List every field that is read or written during the operation, including fields used in calculations and downstream function calls. Omitting a field that is later accessed triggers a second round-trip. - -Fields that appear **only** in SetRange or SetFilter calls do not need to be included — the database resolves the filter using the index without hydrating the value into AL memory. Including filter-only fields wastes bandwidth and is not required. +Before a `Get`, `FindSet`, or `FindFirst` that the procedure follows by reading only a handful of the table's fields, call `SetLoadFields` listing exactly those fields. The pattern `SetLoadFields(...); if Record.Get(...) then ...` is the upstream-endorsed shape. Skip `SetLoadFields` when the table has few fields (under ten), when the code reads most of them (above 60 %), when the loop runs ten or fewer iterations, or when the table is exempt for other reasons (`singleton-setup-tables-need-no-access-optimization.md`, `temporary-tables-have-no-database-cost.md`). For report dataitems, use `AddLoadFields` in `OnPreDataItem` instead (see `addloadfields-in-report-onpredataitem.md`). See sample: `use-setloadfields-for-partial-records.good.al`. ## Anti Pattern -Iterating a large set and reading only two or three fields without SetLoadFields forces the platform to transport every column for every row, including BLOBs and unused text fields. +Loading a wide table and reading one field per row in a loop. The bytes transferred per row are dominated by the columns the procedure does not touch; the SQL query selects them anyway. The same applies to a single `Get` on a wide table — the platform reads the whole row when a single field would have sufficed. See sample: `use-setloadfields-for-partial-records.bad.al`. - diff --git a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.good.al b/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.good.al deleted file mode 100644 index 036eccb..0000000 --- a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.good.al +++ /dev/null @@ -1,17 +0,0 @@ -codeunit 50138 "Perf Sample SingleInstance Good" -{ - SingleInstance = true; - - var - Cached: Record "Sales & Receivables Setup"; - Loaded: Boolean; - - procedure GetSetup(): Record "Sales & Receivables Setup" - begin - if not Loaded then begin - Cached.Get(); - Loaded := true; - end; - exit(Cached); - end; -} diff --git a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md b/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md deleted file mode 100644 index 9c8d6f9..0000000 --- a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [singleinstance, cache, codeunit, session] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use SingleInstance codeunits for session caching - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -A SingleInstance codeunit lives once per session. Variables on it survive across calls, which makes it the natural home for data that is expensive to compute, read often, and stable for the duration of the session — feature flags, configuration snapshots, setup records. Each cached value avoids a SQL read per subsequent call site. - -## Best Practice - -Store long-lived, read-often, rarely-changing data on a SingleInstance codeunit, populated lazily on first access. Keep the cached footprint small: a handful of booleans, a setup record, a few derived values. Be explicit about invalidation if the source can change during the session. - -See sample: `use-single-instance-codeunits-for-caching.good.al`. - -## Anti Pattern - -Reading the same setup record on every call from every caller, instead of caching it, repeats a SQL round-trip that has no business happening more than once per session. - diff --git a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.good.al b/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.good.al deleted file mode 100644 index ecf550f..0000000 --- a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.good.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 50124 "Perf Sample TempTable Good" -{ - procedure BuildAffectedItems(var TempItem: Record Item temporary) - var - SalesLine: Record "Sales Line"; - begin - TempItem.Reset(); - TempItem.DeleteAll(); - SalesLine.SetRange(Type, SalesLine.Type::Item); - if SalesLine.FindSet() then - repeat - TempItem."No." := SalesLine."No."; - if TempItem.Insert(false) then; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md b/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md deleted file mode 100644 index 0beb323..0000000 --- a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [temporary-table, in-memory, intermediate, working-set] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use temporary tables for intermediate data - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Temporary tables live in memory, not in SQL. They are the correct primary data structure for intermediate results, working sets, and lookup caches that do not need to outlive the current operation. Using a real persisted table for scratch data incurs database round-trips, transaction scope, and locking for data that has no business being persisted. - -## Best Practice - -Declare the record variable with `temporary` when the data is scratch. Populate it with Insert(false) to avoid firing triggers. Clear the table explicitly with DeleteAll when the variable's scope is long-lived (a SingleInstance codeunit or a reused session variable) and needs to be reset between uses. - -See sample: `use-temporary-tables-for-intermediate-data.good.al`. - -## Anti Pattern - -Writing intermediate results to a real table, processing them, and deleting them afterwards performs the full cost of INSERT and DELETE operations on data that never needed to be transactional. - diff --git a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.bad.al b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.bad.al deleted file mode 100644 index 97bf89a..0000000 --- a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50932 "Perf Sample TextConcat Bad" -{ - procedure BuildItemList(var Item: Record Item): Text - var - Result: Text; - begin - if Item.FindSet() then - repeat - Result += StrSubstNo('%1,%2', Item."No.", Item.Description); - until Item.Next() = 0; - - exit(Result); - end; -} diff --git a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.good.al b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.good.al deleted file mode 100644 index 417759c..0000000 --- a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50931 "Perf Sample TextBuilder Good" -{ - procedure BuildItemList(var Item: Record Item): Text - var - Builder: TextBuilder; - begin - if Item.FindSet() then - repeat - Builder.AppendLine(StrSubstNo('%1,%2', Item."No.", Item.Description)); - until Item.Next() = 0; - - exit(Builder.ToText()); - end; -} diff --git a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.md b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.md deleted file mode 100644 index 32080b5..0000000 --- a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [textbuilder, string-concatenation, loop, text, allocation] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use TextBuilder for loop-based string assembly - -## Description - -Repeated `Text := Text + ...` concatenation inside a loop reallocates and copies the growing string on every iteration. In AL, `TextBuilder` is the platform type for constructing larger text payloads incrementally. `StrSubstNo` remains appropriate for formatting one message; TextBuilder is for many appends, especially inside loops. - -## Best Practice - -Use `TextBuilder.Append` or `AppendLine` when assembling CSV rows, log payloads, JSON-ish diagnostic text, or other multi-line strings from repeated loop iterations. Convert to Text once, after the loop, with `ToText()`. - -See sample: `use-textbuilder-for-loop-string-assembly.good.al`. - -## Anti Pattern - -Appending to the same Text variable on every iteration of a large loop. Each append copies the accumulated prefix again, so the cost grows with both row count and final string length. - -See sample: `use-textbuilder-for-loop-string-assembly.bad.al`. diff --git a/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md b/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md new file mode 100644 index 0000000..f19636a --- /dev/null +++ b/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [textbuilder, string-concatenation, loop, append, immutable-text] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use TextBuilder for many string concatenations, especially inside loops + +## Description + +AL `Text` is immutable: each `Result += Piece;` allocates a new buffer and copies the previous content into it. Inside a loop the work is quadratic in the number of pieces. `TextBuilder` is the AL primitive designed for the pattern — per the upstream guidance, "Use `TextBuilder` when concatenating many strings together (for example inside loops)." Its `Append` mutates a growable internal buffer; `ToText()` materializes the final string once at the end. + +## Best Practice + +When a procedure assembles a string from many fragments — joining row data into a CSV, accumulating a log buffer, formatting a multi-line message inside a loop — declare a `TextBuilder` local, call `Append` per fragment, and call `ToText()` after the loop. For a fixed number of small fragments, `StrSubstNo` remains the right tool; the rule targets the loop case. + +## Anti Pattern + +`if Customer.FindSet() then repeat Csv += Customer."No." + ',' + Customer.Name + '\n'; until Customer.Next() = 0;` — every iteration reallocates and copies the entire string built so far. On a few hundred customers the cost is invisible; on the production-scale table list (`production-scale-tables-warrant-extra-analysis.md`) it dominates the loop. diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al new file mode 100644 index 0000000..08f1702 --- /dev/null +++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al @@ -0,0 +1,11 @@ +codeunit 50207 "Privacy Sample StrSubstNo Bad" +{ + procedure ReportFailure(var Customer: Record Customer) + var + ErrorMsg: Text; + begin + ErrorMsg := StrSubstNo('Customer %1 (%2) at %3 has invalid data', + Customer.Name, Customer."E-Mail", Customer.Address); + Error(ErrorMsg); + end; +} diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.good.al b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.good.al new file mode 100644 index 0000000..6886e29 --- /dev/null +++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.good.al @@ -0,0 +1,9 @@ +codeunit 50206 "Privacy Sample StrSubstNo Good" +{ + procedure ReportFailure(var Customer: Record Customer) + var + CustomerInvalidErr: Label 'Customer %1 has invalid data (email: %2).', Comment = '%1 = Customer No., %2 = E-Mail'; + begin + Error(CustomerInvalidErr, Customer."No.", Customer."E-Mail"); + end; +} diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md new file mode 100644 index 0000000..7d4c1e7 --- /dev/null +++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [strsubstno, error, telemetry, pii, prebuild, text-variable] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not pre-build an error string with `StrSubstNo` before calling `Error()` + +## Description + +`StrSubstNo` returns a plain `Text` value with the substitutions already performed. When that result is then passed to `Error()`, the platform sees a single plain-text parameter with no field references left to inspect, so it cannot apply `DataClassification` to anything inside it. Whatever PII the `StrSubstNo` call interpolated — customer name, e-mail, address, error text — is logged verbatim to telemetry. This is the canonical way to accidentally leak customer data through error telemetry, and it is the only `Error()` shape that needs to be flagged. + +## Best Practice + +Call `Error()` directly with the format string and the substitution parameters. The platform classifies each parameter individually and handles telemetry correctly even when the parameters are PII fields (see `error-direct-substitution-safe-for-telemetry.md`). If the message text needs to be a `Label`, pass the `Label` and the parameters to `Error()` — do not pre-render via `StrSubstNo`. + +See sample: `avoid-strsubstno-prebuild-before-error.good.al`. + +## Anti Pattern + +Assigning `StrSubstNo('Customer %1 (%2) ...', Customer.Name, Customer."E-Mail")` to a `Text` variable and then calling `Error(ErrorMsg)`. The platform has nothing to classify by the time `Error` runs — the PII is baked into the string and goes straight to telemetry. Detection signal for a reviewer: any `Text` variable assigned from `StrSubstNo` and later passed as the *only* parameter to `Error()`. + +See sample: `avoid-strsubstno-prebuild-before-error.bad.al`. diff --git a/microsoft/knowledge/privacy/classify-data-at-migration-destination.bad.al b/microsoft/knowledge/privacy/classify-data-at-migration-destination.bad.al deleted file mode 100644 index 4300fd9..0000000 --- a/microsoft/knowledge/privacy/classify-data-at-migration-destination.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -table 50936 "Migrated Employee" -{ - fields - { - field(1; "Employee No."; Code[20]) - { - DataClassification = ToBeClassified; - } - field(2; "Tax Identification No."; Text[30]) - { - DataClassification = SystemMetadata; - } - } -} diff --git a/microsoft/knowledge/privacy/classify-data-at-migration-destination.good.al b/microsoft/knowledge/privacy/classify-data-at-migration-destination.good.al deleted file mode 100644 index 44204a5..0000000 --- a/microsoft/knowledge/privacy/classify-data-at-migration-destination.good.al +++ /dev/null @@ -1,14 +0,0 @@ -table 50935 "Migrated Employee" -{ - fields - { - field(1; "Employee No."; Code[20]) - { - DataClassification = EndUserPseudonymousIdentifiers; - } - field(2; "Tax Identification No."; Text[30]) - { - DataClassification = EndUserIdentifiableInformation; - } - } -} diff --git a/microsoft/knowledge/privacy/classify-data-at-migration-destination.md b/microsoft/knowledge/privacy/classify-data-at-migration-destination.md deleted file mode 100644 index f582fcc..0000000 --- a/microsoft/knowledge/privacy/classify-data-at-migration-destination.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [migration, dataclassification, hybrid, destination, pii] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Classify migrated data at the destination field - -## Description - -Hybrid migration codeunits such as HybridSL, HybridGP, and HybridBC legitimately process sensitive source data: tax IDs, employee identifiers, financial balances, and customer records. The privacy concern is not that the migration code touches the data. The concern is where the data lands: the destination table field must have a DataClassification value that matches the migrated content. - -## Best Practice - -When reviewing migration code, follow the assignment to the destination field and verify that the destination table declares an appropriate field-level or inherited DataClassification. Treat the migration procedure itself as expected business functionality; flag only missing or understated classification on the persistent destination. - -See sample: `classify-data-at-migration-destination.good.al`. - -## Anti Pattern - -Flagging a migration procedure merely because it copies tax IDs or names from a source system. That creates false positives and misses the real issue: a destination field with no classification, `ToBeClassified`, or `SystemMetadata` for customer or employee data. - -See sample: `classify-data-at-migration-destination.bad.al`. diff --git a/microsoft/knowledge/privacy/data-classification-is-table-field-property.md b/microsoft/knowledge/privacy/data-classification-is-table-field-property.md new file mode 100644 index 0000000..acfc0e7 --- /dev/null +++ b/microsoft/knowledge/privacy/data-classification-is-table-field-property.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-classification, page-field, table-field, api-page, card-page, list-page] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# DataClassification is a table-field property, not a page-field property + +## Description + +`DataClassification` is defined on table fields. Pages — including `Card`, `List`, `API`, and `ListPart` — do not own a classification; they simply expose fields whose classification is inherited from the underlying table. A page-level `DataClassification` property does not exist, so neither a missing nor a "wrong" classification can be reported against a page. When the underlying table field is misclassified, the fix is on the table definition, not on every page that surfaces the field. + +## Best Practice + +When reviewing a page that exposes a field believed to be under-classified, follow the field back to its source table and inspect (or correct) the `DataClassification` there. A single corrected table field propagates to every page, report and API that uses it. + +## Anti Pattern + +Flagging a page (or trying to add a `DataClassification` property to a page field) because the page displays personal data. Pages display data that authenticated, permissioned users are already entitled to see; the classification belongs on the table field that stores the data, not on the UI that renders it. diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.bad.al b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.bad.al new file mode 100644 index 0000000..72b2174 --- /dev/null +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.bad.al @@ -0,0 +1,11 @@ +tableextension 50201 "Customer Contact Ext Bad" extends Customer +{ + fields + { + field(50201; "Secondary Email"; Text[80]) + { + DataClassification = SystemMetadata; + Caption = 'Secondary Email'; + } + } +} diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.good.al b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.good.al new file mode 100644 index 0000000..aef9301 --- /dev/null +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.good.al @@ -0,0 +1,11 @@ +tableextension 50200 "Customer Contact Ext" extends Customer +{ + fields + { + field(50200; "Secondary Email"; Text[80]) + { + DataClassification = CustomerContent; + Caption = 'Secondary Email'; + } + } +} diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md new file mode 100644 index 0000000..808e103 --- /dev/null +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-classification, pii, gdpr, customer-content, table-field, under-classified] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# DataClassification is required on table fields containing sensitive data + +## Description + +`DataClassification` is the AL property that tells the platform what kind of data a table field stores so that telemetry, GDPR data-subject requests, and the platform's audit surfaces can treat it correctly. It is required on any field that holds personal or customer data. The default value `SystemMetadata` means "no user or customer data" — applying it to a field that actually holds PII (an email address, a customer name, an employee code) is an under-classification and a privacy bug, even though the code still compiles. + +## Best Practice + +Set `DataClassification` to the value that matches the data the field actually stores. A `Customer."E-Mail"`-style field is `CustomerContent` (data belonging to the tenant's customers); a personal identifier such as an employee number or user ID is `EndUserIdentifiableInformation` or `EndUserPseudonymousIdentifiers` depending on whether it is directly identifying. Choose the classification at field definition time — fixing it later is a schema change. + +See sample: `data-classification-required-on-pii-fields.good.al`. + +## Anti Pattern + +Declaring a field that stores PII with `DataClassification = SystemMetadata` to silence the compiler warning. The field compiles but the platform now treats customer data as system metadata in telemetry, GDPR exports and admin reports. + +See sample: `data-classification-required-on-pii-fields.bad.al`. diff --git a/microsoft/knowledge/privacy/dataclassification-is-a-table-field-property.md b/microsoft/knowledge/privacy/dataclassification-is-a-table-field-property.md deleted file mode 100644 index c47955f..0000000 --- a/microsoft/knowledge/privacy/dataclassification-is-a-table-field-property.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [dataclassification, table-field, page, api-page, scope] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# DataClassification is a table-field property, not a page property - -## Description - -DataClassification governs how the platform handles a field's data in telemetry, data-subject requests, and retention tooling. It is declared on the table field, not on the page that displays the field. Pages — card pages, list pages, API pages — simply render fields sourced from a table. A privacy issue with classification is always an issue on the table definition; the page is a display surface. - -## Best Practice - -Flag missing or wrong DataClassification on the table field where the data lives. When a field is exposed through an API page or any other page type, the source table's classification governs. Do not report the same issue on every page that happens to include the field. - -## Anti Pattern - -Reporting a privacy finding on `page 50100 "Customer API"` because it exposes an email field, rather than on `table Customer`'s email field. Fix at the source; the page is not the offender and the same correction applied per-page produces churn without changing the data-classification story. diff --git a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al b/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al deleted file mode 100644 index 718983a..0000000 --- a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -tableextension 50911 "Privacy Sample IS Bad" extends "Sales & Receivables Setup" -{ - fields - { - // Refactor moves the delta URL out of encrypted IsolatedStorage into a - // plain table field. Value is now plaintext in SQL, unscoped, indistinguishable - // from non-sensitive content. - field(50100; "Delta Url"; Text[250]) - { - DataClassification = EndUserPseudonymousIdentifiers; - } - } -} diff --git a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.good.al b/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.good.al deleted file mode 100644 index b54ce3a..0000000 --- a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50910 "Privacy Sample IS Good" -{ - procedure StoreDeltaUrl(DeltaUrl: Text) - var - DeltaKeyTok: Label 'SyncDeltaUrl', Locked = true; - begin - // Sensitive delta URL remains encrypted and scoped to the extension. - IsolatedStorage.SetEncrypted(DeltaKeyTok, DeltaUrl, DataScope::Company); - end; -} diff --git a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.md b/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.md deleted file mode 100644 index 4e64bff..0000000 --- a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [isolatedstorage, encryption, tokens, refactor, regression] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not move PII or secrets from IsolatedStorage to plain table fields - -## Description - -IsolatedStorage with SetEncrypted keeps sensitive values — tokens, URLs carrying identifiers, delta cursors with embedded user context — encrypted at rest and scoped to the extension. Moving the same value to a normal table field is a refactor that looks structural but is a privacy and security regression: the value is now plaintext in SQL, visible to every reader of that table, backed up and replicated as ordinary business data. Reviews of existing integrations frequently see this change justified as "easier to query" — the concern is the storage model, not the ergonomics. - -## Best Practice - -Keep tokens, secrets, personal-context URLs, and similar sensitive values in IsolatedStorage (SetEncrypted) or Azure Key Vault. When a refactor moves the value, require an explicit justification and a mitigating control (restricted-read permission set, value-level encryption, redaction in the access path). Otherwise leave it where it was. - -See sample: `do-not-move-pii-from-isolated-storage-to-plain-fields.good.al`. - -## Anti Pattern - -A diff that deletes an `IsolatedStorage.SetEncrypted` call and writes the same value into a new `Text` column on a business table. The value is now unencrypted, unscoped, and indistinguishable from non-sensitive content to any caller reading the table. - -See sample: `do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al`. diff --git a/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al new file mode 100644 index 0000000..1b8c886 --- /dev/null +++ b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al @@ -0,0 +1,10 @@ +codeunit 50205 "Privacy Sample Direct Error" +{ + procedure ValidateCustomer(var Customer: Record Customer) + var + InvalidEmailErr: Label 'Customer %1 has an invalid e-mail address: %2.', Comment = '%1 = Customer No., %2 = E-Mail'; + begin + if not Customer."E-Mail".Contains('@') then + Error(InvalidEmailErr, Customer."No.", Customer."E-Mail"); + end; +} diff --git a/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md new file mode 100644 index 0000000..8653ecf --- /dev/null +++ b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [error, strsubstno, direct-substitution, telemetry, classification, label] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `Error()` with direct substitution parameters is always safe for telemetry + +## Description + +When `Error()` is called with a format string and direct substitution parameters (`%1`, `%2`, …), the BC platform intercepts the call, inspects each parameter individually, and applies the `DataClassification` of the source field — stripping or masking sensitive data before writing the message to telemetry. This is true regardless of whether a parameter is a record field reference, a local variable, a function return value, or any other expression. Patterns such as `Error('Invalid email: %1', Customer."E-Mail")` are therefore safe even when the parameter is PII: the platform sees `Customer."E-Mail"` as a `CustomerContent` field reference and handles it correctly. + +## Best Practice + +Pass values to `Error()` as direct substitution parameters — either inline or via a `Label` with `Comment = '%1 = …'` placeholders. Let the platform do the per-parameter classification. This works equally well for record fields, local text variables, and document IDs. + +See sample: `error-direct-substitution-safe-for-telemetry.good.al`. + +## Anti Pattern + +Treating any `Error()` call that mentions PII as a leak. A review skill that flags `Error('Invalid email: %1', EmailAddress)` is wrong; the platform handles that pattern correctly. The only `Error()` shape that genuinely leaks PII to telemetry is the pre-built `StrSubstNo` form covered in `avoid-strsubstno-prebuild-before-error.md`. diff --git a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.bad.al b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.bad.al deleted file mode 100644 index f06f679..0000000 --- a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.bad.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 50903 "Privacy Sample ErrorVsMsg Bad" -{ - procedure ConfirmThenFail(var Customer: Record Customer) - var - ConfirmQst: Label 'Send welcome email to %1 at %2?', Comment = '%1 = name, %2 = email'; - FailureWithPiiErr: Text; - begin - if not Confirm(ConfirmQst, false, Customer.Name, Customer."E-Mail") then - exit; - - // Pre-built Text with PII, passed to Error: customer name and email reach telemetry. - FailureWithPiiErr := StrSubstNo( - 'Could not send welcome to %1 at %2.', Customer.Name, Customer."E-Mail"); - Error(FailureWithPiiErr); - end; -} diff --git a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.good.al b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.good.al deleted file mode 100644 index a3a4b25..0000000 --- a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.good.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50902 "Privacy Sample ErrorVsMsg Good" -{ - procedure ConfirmThenFail(var Customer: Record Customer) - var - ConfirmQst: Label 'Send welcome email to %1 at %2?', Comment = '%1 = name, %2 = email'; - GenericFailureErr: Label 'The welcome email could not be sent.'; - begin - // Confirm is not logged to telemetry. PII in the prompt is fine. - if not Confirm(ConfirmQst, false, Customer.Name, Customer."E-Mail") then - exit; - - // Error is logged. Keep PII out of the message. - Error(GenericFailureErr); - end; -} diff --git a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md deleted file mode 100644 index 694c640..0000000 --- a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [error, message, confirm, notification, telemetry, pii] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Error logs to telemetry; Message, Confirm, and Notification do not - -## Description - -The privacy concern with user-facing text is not what the authenticated user sees — it is what the platform exports to telemetry. Error is captured automatically; Message, Confirm, StrMenu, and Notification are not. Reviews that flag PII in any user-facing dialog over-report. Reviews that ignore PII in Error under-report. The distinction is the delivery surface, not the presence of a person's name on screen. - -## Best Practice - -Free-text business content — customer names, email addresses, document numbers — is acceptable in Message, Confirm, and Notification. Treat Error text as if it will be read by telemetry consumers, because it will be, but use direct Error substitution rather than pre-building the message. `Error(MyErr, EmailAddress)` is telemetry-safe; `Error(StrSubstNo(..., EmailAddress))` is not. - -See sample: `error-is-logged-to-telemetry-message-is-not.good.al`. - -## Anti Pattern - -Embedding customer emails, phone numbers, addresses, or names as literals in an Error label or baking them into a Text value with StrSubstNo before calling Error. The user also sees Message and Confirm, but those are not logged. Error is logged, so dynamic customer data must stay as direct substitution arguments. - -See sample: `error-is-logged-to-telemetry-message-is-not.bad.al`. diff --git a/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md b/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md new file mode 100644 index 0000000..f7372b7 --- /dev/null +++ b/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [error, message, confirm, notification, telemetry, logging, ui-dialog] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Only `Error()` is logged to telemetry — `Message`, `Confirm`, `Notification` are not + +## Description + +The privacy concern with dialog APIs is not what the signed-in user sees on the screen — it is what the platform writes to telemetry. The BC platform automatically captures `Error()` invocations in the telemetry stream; it does not capture `Message()`, `Confirm()` or `Notification` calls. That asymmetry is the reason privacy review focuses on `Error()` text and ignores the other dialog APIs: a `Message` that shows a customer's email to the signed-in user reveals nothing they were not already entitled to see, while an `Error` carrying the same email leaks it to a separate, longer-lived telemetry destination. + +## Best Practice + +Treat `Error()` as a telemetry surface, not just a UI surface — review the message text and parameters with the same scrutiny you apply to `Session.LogMessage`. Treat `Message()`, `Confirm()`, and `Notification` as pure UI: showing business data the user is permissioned for is normal functionality. + +## Anti Pattern + +Flagging `Message`/`Confirm`/`Notification` calls for "showing PII" — they are not logged to telemetry, and the user already has permission to the underlying data. The inverse anti-pattern is treating `Error()` as harmless because the user sees only a dialog: the message is also written verbatim to telemetry. diff --git a/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.bad.al b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.bad.al new file mode 100644 index 0000000..8225269 --- /dev/null +++ b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.bad.al @@ -0,0 +1,12 @@ +codeunit 50215 "Privacy Sample FeatureTelemetry Bad" +{ + procedure LogDocumentReleased(ExpenseHeader: Record "Sales Header"; var User: Record User) + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + CustomDimensions: Dictionary of [Text, Text]; + begin + CustomDimensions.Add('EmployeeNo', ExpenseHeader."Sell-to Customer No."); + CustomDimensions.Add('UserName', User."Full Name"); + FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions); + end; +} diff --git a/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.good.al b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.good.al new file mode 100644 index 0000000..6172f9d --- /dev/null +++ b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.good.al @@ -0,0 +1,10 @@ +codeunit 50214 "Privacy Sample FeatureTelemetry Good" +{ + procedure LogUptake() + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + FeatureTelemetry.LogUptake('0000EA2', 'Expense Agent', + Enum::"Feature Uptake Status"::"Set up"); + end; +} diff --git a/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md new file mode 100644 index 0000000..6d8bfb5 --- /dev/null +++ b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [feature-telemetry, customdimensions, logusage, loguptake, logerror, pii, euii, eupi] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `FeatureTelemetry` `CustomDimensions` follow the same privacy rules as `Session.LogMessage` + +## Description + +`Codeunit "Feature Telemetry"` is the second telemetry surface in AL. Its methods — `LogUsage()`, `LogUptake()` and `LogError()` — each accept a `CustomDimensions` dictionary parameter whose contents are sent to telemetry as-is. The platform does not classify per-dimension values for you, so any customer or employee data placed into the dictionary is logged verbatim. The privacy rules that apply to `Session.LogMessage` message text apply to every value in `CustomDimensions`: no customer or employee names, email addresses, phone numbers (`CustomerContent`/EUII); no employee codes, user IDs or user security IDs (EUPI); no user-provided content (addresses, descriptions, notes); no `GetLastErrorText()` output. + +## Best Practice + +Pass only non-personal context through `CustomDimensions` — feature names, status enums, counts, error codes, durations. For uptake or usage signals that do not need per-call context, prefer the parameterless overload of `LogUptake`/`LogUsage` over a `CustomDimensions` dictionary that risks accreting PII over time. + +See sample: `featuretelemetry-customdimensions-no-pii.good.al`. + +## Anti Pattern + +`CustomDimensions.Add('EmployeeNo', ExpenseHeader."Employee No.")` followed by `FeatureTelemetry.LogUsage(...)` — the employee number is a pseudonymous user identifier (EUPI) and is now in telemetry. Same pattern with `'UserName'`, `'CustomerEmail'`, `'AttachmentName'` etc. + +See sample: `featuretelemetry-customdimensions-no-pii.bad.al`. diff --git a/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.good.al b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.good.al new file mode 100644 index 0000000..c31ced8 --- /dev/null +++ b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.good.al @@ -0,0 +1,18 @@ +tableextension 50203 "Customer Order Stats" extends Customer +{ + fields + { + field(50203; "Open Order Count"; Integer) + { + FieldClass = FlowField; + CalcFormula = count("Sales Header" where("Sell-to Customer No." = field("No."))); + Caption = 'Open Order Count'; + } + + field(50204; "Date Filter"; Date) + { + FieldClass = FlowFilter; + Caption = 'Date Filter'; + } + } +} diff --git a/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md new file mode 100644 index 0000000..d3a1b7b --- /dev/null +++ b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [flowfield, flowfilter, data-classification, systemmetadata, calculated] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# FlowFields and FlowFilters are classified `SystemMetadata` automatically + +## Description + +`FlowField` and `FlowFilter` are not stored fields — a FlowField is computed from a CalcFormula at read time and a FlowFilter is a transient filter scoped to the record variable. Because nothing is ever written to the database for these fields, the platform automatically classifies them as `DataClassification = SystemMetadata` and AL does not require — or expect — the developer to set `DataClassification` on them. A FlowField that surfaces PII (e.g., a sum or lookup over a `CustomerContent` table) is still `SystemMetadata` at the FlowField level; the privacy classification lives on the underlying stored field that the CalcFormula references. + +## Best Practice + +Do not declare `DataClassification` on `FieldClass = FlowField` or `FieldClass = FlowFilter` fields — the inherited `SystemMetadata` is correct and the property is redundant. If a FlowField exposes sensitive data, ensure the underlying source field has the right `DataClassification`; that is where the platform reads classification from for GDPR and telemetry purposes. + +See sample: `flowfield-flowfilter-classification-systemmetadata.good.al`. + +## Anti Pattern + +Flagging a FlowField for "missing `DataClassification`" or trying to override it to `CustomerContent` because the formula references customer data. The platform's automatic `SystemMetadata` value is the documented, intentional behavior for non-stored fields; overriding it adds nothing and misrepresents the field as if it were stored. diff --git a/microsoft/knowledge/privacy/flowfields-auto-inherit-systemmetadata.md b/microsoft/knowledge/privacy/flowfields-auto-inherit-systemmetadata.md deleted file mode 100644 index 5b049d3..0000000 --- a/microsoft/knowledge/privacy/flowfields-auto-inherit-systemmetadata.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [flowfield, flowfilter, dataclassification, systemmetadata, default] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# FlowFields and FlowFilters automatically inherit DataClassification SystemMetadata - -## Description - -FlowFields and FlowFilters are virtual — they carry no stored data of their own, and their values are computed on demand from the source table the CalcFormula references. The platform classifies them as SystemMetadata automatically and does not require (or respect) a per-field DataClassification declaration. Flagging a FlowField as missing DataClassification, or as under-classified because the computed value may be CustomerContent, is a false positive: the underlying source field carries the classification that matters, and that is what telemetry and compliance tooling inspects. - -## Best Practice - -Leave DataClassification off FlowFields and FlowFilters. If the computed value is sensitive, the fix is to ensure the source table's field has the correct classification. Verify source-field classification rather than trying to re-classify the computed view. - -## Anti Pattern - -Reporting "missing DataClassification" on a FlowField, or attempting to set a FlowField's DataClassification to CustomerContent because the SUM aggregates a sensitive amount. The declaration has no effect; the platform uses the source-field classification. diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al new file mode 100644 index 0000000..b943031 --- /dev/null +++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al @@ -0,0 +1,17 @@ +codeunit 50209 "Privacy Sample GetLastError Bad" +{ + procedure AddAttachment() + var + ErrorMsg: Text; + begin + if not TryAddAttachment() then begin + ErrorMsg := StrSubstNo('Attachment failed: %1', GetLastErrorText(true)); + Error(ErrorMsg); + end; + end; + + [TryFunction] + local procedure TryAddAttachment() + begin + end; +} diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al new file mode 100644 index 0000000..4b07537 --- /dev/null +++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al @@ -0,0 +1,16 @@ +codeunit 50208 "Privacy Sample GetLastError Good" +{ + procedure AddAttachmentSafely() + var + AttachmentFailedErr: Label 'Failed to add email attachment. Please try again.'; + begin + if not TryAddAttachment() then + Error(AttachmentFailedErr); + end; + + [TryFunction] + local procedure TryAddAttachment() + begin + // ... attachment logic that may fail with a customer-data-bearing error ... + end; +} diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md new file mode 100644 index 0000000..769a3f5 --- /dev/null +++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [getlasterrortext, error, strsubstno, telemetry, customer-data, attachment] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Treat `GetLastErrorText()` as potential customer content + +## Description + +`GetLastErrorText()` returns the text of the last error that occurred in the context where it is called. That text routinely contains customer content — field values that triggered the validation, record keys, customer names, file names from upload failures, and similar fragments lifted from the failing operation. Re-emitting it through `StrSubstNo` into `Error()` bakes that customer data into a single plain-text parameter that the platform can no longer classify, so it is logged verbatim to telemetry (the same problem as any other `StrSubstNo`-pre-built error — see `avoid-strsubstno-prebuild-before-error.md`). + +## Best Practice + +When the goal is to surface a recoverable failure to the user, raise a generic message that does not embed `GetLastErrorText()` content, and log technical detail separately via `Session.LogMessage` with the correct `DataClassification`. If you must propagate the inner error verbatim, re-raise it as a direct parameter of `Error()` (e.g., `Error('%1', GetLastErrorText())`) rather than concatenating with `StrSubstNo` so the platform can apply its own handling. + +See sample: `getlasterrortext-customer-content-in-errors.good.al`. + +## Anti Pattern + +`ErrorMsg := StrSubstNo('Attachment failed: %1', GetLastErrorText(true)); Error(ErrorMsg);` — the inner error text may carry filenames or record values, and `StrSubstNo` strips the platform's ability to filter them before they hit telemetry. + +See sample: `getlasterrortext-customer-content-in-errors.bad.al`. diff --git a/microsoft/knowledge/privacy/in-memory-data-is-not-a-privacy-concern.md b/microsoft/knowledge/privacy/in-memory-data-is-not-a-privacy-concern.md deleted file mode 100644 index 415525f..0000000 --- a/microsoft/knowledge/privacy/in-memory-data-is-not-a-privacy-concern.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [memory, dictionary, list, temporary-record, scope, false-positive] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# In-memory variables are not a privacy concern in Business Central - -## Description - -Business Central runs in a managed server environment. Local variables, Dictionary, List, and temporary Record buffers exist only for the duration of the request or session; the runtime reclaims them when the scope exits. Memory dumps are not a realistic threat vector in this architecture, and flagging an in-memory collection of customer emails or names as a privacy issue misstates the product's security model. - -## Best Practice - -Focus privacy review on persistence, transit, and telemetry: what is written to tables, sent over the network, or logged. Treat in-memory handling of personal data as normal business functionality. When an in-memory buffer is copied into IsolatedStorage, a table, or a telemetry call, that downstream write is what gets reviewed. - -## Anti Pattern - -Flagging `Dictionary of [Code[20], Text]`, `List of [Text]`, or `Record Customer temporary` variables that hold customer data during a calculation as a privacy concern. The flag is a false positive that trains authors to avoid a normal pattern and distracts from the persistent storage that does matter. diff --git a/microsoft/knowledge/privacy/in-memory-data-not-a-privacy-concern.md b/microsoft/knowledge/privacy/in-memory-data-not-a-privacy-concern.md new file mode 100644 index 0000000..38fb6f2 --- /dev/null +++ b/microsoft/knowledge/privacy/in-memory-data-not-a-privacy-concern.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [in-memory, dictionary, list, temporary-table, variable, memory-dump] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# In-memory variables, dictionaries, lists and temporary tables are not a privacy concern + +## Description + +AL runs in a managed server environment. Local variables, `Dictionary`, `List`, temporary `Record` variables, and other in-process data structures exist only for the duration of the request or session and are released by the runtime when it ends — they are not persisted, not visible across sessions, and not exposed outside the server process. Memory dumps are not a realistic threat vector against Business Central's hosted architecture, so holding business data (emails, names, addresses, document content) in these structures while processing a request is normal and expected. + +## Best Practice + +Use whatever in-memory shape (`Dictionary`, `List`, temporary tables, plain variables) the algorithm needs. The privacy review applies to *persistent* surfaces — table fields, telemetry, outgoing HTTP — not to per-request memory. + +## Anti Pattern + +Flagging a `Dictionary of [Text, Text]` populated with customer emails, or a temporary `Record Customer` holding rows mid-processing, as a privacy leak. These structures are scoped to the request and do not leave the server's memory. diff --git a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.bad.al b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.bad.al deleted file mode 100644 index 7411a0e..0000000 --- a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50936 "Privacy FeatureTelemetry Bad" -{ - procedure LogExpenseReleased(EmployeeNo: Code[20]; UserName: Text) - var - FeatureTelemetry: Codeunit "Feature Telemetry"; - CustomDimensions: Dictionary of [Text, Text]; - begin - CustomDimensions.Add('EmployeeNo', EmployeeNo); - CustomDimensions.Add('UserName', UserName); - CustomDimensions.Add('LastError', GetLastErrorText()); - - FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions); - end; -} diff --git a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.good.al b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.good.al deleted file mode 100644 index 9300406..0000000 --- a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.good.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50935 "Privacy FeatureTelemetry Good" -{ - procedure LogExpenseReleased() - var - FeatureTelemetry: Codeunit "Feature Telemetry"; - CustomDimensions: Dictionary of [Text, Text]; - begin - CustomDimensions.Add('DocumentType', 'Expense'); - CustomDimensions.Add('LineCountBucket', '10-20'); - - FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions); - end; -} diff --git a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.md b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.md deleted file mode 100644 index d9c1bc7..0000000 --- a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [featuretelemetry, customdimensions, telemetry, pii, customercontent] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Keep customer data out of FeatureTelemetry custom dimensions - -## Description - -`Codeunit "Feature Telemetry"` writes telemetry through methods such as `LogUsage`, `LogUptake`, and `LogError`. The `CustomDimensions` dictionary passed to those methods is exported to the telemetry pipeline, so it has the same privacy boundary as `Session.LogMessage` dimensions. Customer names, email addresses, employee numbers, user IDs, security IDs, notes, and `GetLastErrorText()` do not become safe merely because they are structured dimensions. - -## Best Practice - -Log feature state, event names, counts, enum values, and non-personal technical identifiers. Omit customer and employee identifiers from `CustomDimensions`; if diagnostics need correlation, use a non-personal event ID or aggregate count instead. - -See sample: `keep-customer-data-out-of-featuretelemetry-dimensions.good.al`. - -## Anti Pattern - -Adding employee numbers, user names, customer emails, free-text descriptions, or raw `GetLastErrorText()` to the `CustomDimensions` dictionary before calling `FeatureTelemetry.LogUsage`, `LogUptake`, or `LogError`. - -See sample: `keep-customer-data-out-of-featuretelemetry-dimensions.bad.al`. diff --git a/microsoft/knowledge/privacy/migration-destination-classification.md b/microsoft/knowledge/privacy/migration-destination-classification.md new file mode 100644 index 0000000..afb8e8d --- /dev/null +++ b/microsoft/knowledge/privacy/migration-destination-classification.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-migration, hybridsl, hybridgp, hybridbc, destination-classification, ssn, tin] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# In data migration code, classify the destination — not the migration itself + +## Description + +Migration codeunits such as `HybridSL`, `HybridGP`, and `HybridBC` exist to copy sensitive data — TINs, Federal IDs, social security numbers, financial records — from a source system into Business Central. The fact that PII flows through these codeunits is the entire point of their existence, not a defect. The privacy concern is whether the destination field where the data lands carries the correct `DataClassification`. If it does, the migration is doing its job; if it doesn't, the right fix is on the destination table field, never on the migration code that writes to it. + +## Best Practice + +When reviewing a migration codeunit, trace each `Dest."" := Source.""` assignment to the destination field's `DataClassification`. Confirm that fields receiving PII (SSNs, Federal IDs, customer names, addresses) are classified `EndUserIdentifiableInformation` or `CustomerContent` as appropriate — and not left as `SystemMetadata` or `ToBeClassified`. + +## Anti Pattern + +Flagging the migration code itself for "processing sensitive data" or recommending that it filter, hash, or skip PII fields — these tables exist to migrate that data. The actionable finding is always on the destination field's classification, not on the migration's assignment statement. diff --git a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al new file mode 100644 index 0000000..06970e2 --- /dev/null +++ b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al @@ -0,0 +1,21 @@ +codeunit 50213 "Privacy Sample Telemetry Bad" +{ + procedure LogCustomerProcessed(var Customer: Record Customer) + begin + Session.LogMessage('0000', StrSubstNo('Processed %1', Customer.Name), Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::All, + 'Category', 'Privacy'); + end; + + procedure LogFileError(FileName: Text) + begin + Session.LogMessage('0001', StrSubstNo('Error processing file %1', FileName), Verbosity::Error, + DataClassification::SystemMetadata, TelemetryScope::All); + end; + + procedure LogEmployeeUpdate(EmployeeCode: Code[20]) + begin + Session.LogMessage('0002', StrSubstNo('Employee %1 updated record', EmployeeCode), Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::All); + end; +} diff --git a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al new file mode 100644 index 0000000..96a553a --- /dev/null +++ b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al @@ -0,0 +1,15 @@ +codeunit 50212 "Privacy Sample Telemetry Good" +{ + procedure LogCustomerProcessed(var Customer: Record Customer) + begin + Session.LogMessage('0000', 'Customer record processed', Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::All, + 'Category', 'Privacy'); + end; + + procedure LogFileError() + begin + Session.LogMessage('0001', 'Error processing uploaded file', Verbosity::Error, + DataClassification::SystemMetadata, TelemetryScope::All); + end; +} diff --git a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md new file mode 100644 index 0000000..ba95fbe --- /dev/null +++ b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [telemetry, session-logmessage, strsubstno, pii, customer-data, employee-code, filename] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not embed customer data in the telemetry message text + +## Description + +`Session.LogMessage`'s message argument is a plain `Text`. Unlike `Error()`, the platform does not inspect this string field-by-field — whatever is in the text is what telemetry receives. So a call that builds the message via `StrSubstNo` from customer-bearing fields ships those values to telemetry verbatim, regardless of the `DataClassification` argument on the same call. Flagged content includes customer names, email addresses, phone numbers, addresses, employee codes or IDs, attachment filenames, user-provided text that may carry PII, and dumps of `Record` content. + +## Best Practice + +Keep the telemetry message a static, non-personal string ("Customer record processed", "Error processing uploaded file"). When structured context is genuinely needed, attach it through custom dimensions, where individual values can be reviewed and classified at the dimension level rather than baked into a free-text message. + +See sample: `no-pii-in-telemetry-message-string.good.al`. + +## Anti Pattern + +`Session.LogMessage('0000', StrSubstNo('Processed %1', Customer.Name), ...)` — the customer name is in telemetry the moment the line runs. Detection signal: a `StrSubstNo` whose result is the second argument of `Session.LogMessage`. The same shape with `FileName`, `EmployeeCode`, or any record field is the same problem. + +See sample: `no-pii-in-telemetry-message-string.bad.al`. diff --git a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.bad.al b/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.bad.al deleted file mode 100644 index cf1b8fa..0000000 --- a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.bad.al +++ /dev/null @@ -1,18 +0,0 @@ -table 50909 "Privacy Sample Override Bad" -{ - DataClassification = SystemMetadata; - - fields - { - field(1; "Entry No."; Integer) { } - // Customer name inherits SystemMetadata from the table. Subject-access - // and retention tooling treats the value as system housekeeping. - field(2; "Customer Name"; Text[100]) { } - field(3; "E-Mail"; Text[80]) { } - field(4; "Logged At"; DateTime) { } - } - keys - { - key(PK; "Entry No.") { Clustered = true; } - } -} diff --git a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.good.al b/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.good.al deleted file mode 100644 index 2670601..0000000 --- a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.good.al +++ /dev/null @@ -1,23 +0,0 @@ -table 50908 "Privacy Sample Override Good" -{ - DataClassification = SystemMetadata; - - fields - { - field(1; "Entry No."; Integer) { } - field(2; "Customer Name"; Text[100]) - { - // Table default is SystemMetadata; this field is personal data. - DataClassification = CustomerContent; - } - field(3; "E-Mail"; Text[80]) - { - DataClassification = CustomerContent; - } - field(4; "Logged At"; DateTime) { } - } - keys - { - key(PK; "Entry No.") { Clustered = true; } - } -} diff --git a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.md b/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.md deleted file mode 100644 index 677e9c4..0000000 --- a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [dataclassification, inheritance, table-level, field-level, override] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Override inherited DataClassification when a field doesn't fit the table default - -## Description - -When a table declares `DataClassification` at the table level, every field inherits that value unless the field declares its own. This is efficient for homogeneous tables — a SystemMetadata log table whose fields are all system-generated, a CustomerContent transaction table whose fields are all business data. It is a privacy regression when a table is classified SystemMetadata but contains a field that holds personal data: the field silently inherits the wrong classification, and telemetry tooling treats its content as safe to log when it is not. - -## Best Practice - -Review every field on a table with a table-level DataClassification. Fields whose content matches the table's default need no per-field declaration. Fields that carry a different kind of data — a customer name on an otherwise-system-metadata log table, a personal identifier on a mixed-content table — must declare their own DataClassification that overrides the table default. - -See sample: `override-inherited-dataclassification-per-field.good.al`. - -## Anti Pattern - -A table declared `DataClassification = SystemMetadata` with fields like `Customer Name`, `E-Mail`, `Phone No.` — the fields inherit SystemMetadata, which is wrong for CustomerContent. Subject-access-request and retention tooling treats the personal data as system housekeeping. - -See sample: `override-inherited-dataclassification-per-field.bad.al`. diff --git a/microsoft/knowledge/privacy/page-display-is-not-a-privacy-concern.md b/microsoft/knowledge/privacy/page-display-is-not-a-privacy-concern.md new file mode 100644 index 0000000..6e8d62d --- /dev/null +++ b/microsoft/knowledge/privacy/page-display-is-not-a-privacy-concern.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [page, card, list, api, listpart, permission-system, display, ui-dialog] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Displaying fields on a page (or in a UI dialog) is not a privacy concern + +## Description + +Every page in Business Central — `Card`, `List`, `API`, `ListPart`, request pages — renders data to an authenticated user who has been granted permission to see it. The BC permission system, not the page definition, controls who sees what; once a user is permissioned to a table, displaying any field of that table is normal business functionality. The same logic extends to `Message`, `Notification` and `Confirm` dialogs: the signed-in user already has access to the data the dialog is showing them. Privacy review for pages and dialogs is therefore the wrong layer — the actionable findings live on the underlying data (table-field classification, telemetry message text, outbound HTTP consent), not on the UI. + +## Best Practice + +When asked "is it OK to show this email/name/employee code on this page?", the answer is yes — provided the user has permission to the underlying record. Drive privacy concerns to the data layer (classification, telemetry, external transfer) rather than the UI layer. + +## Anti Pattern + +Flagging an API page, list, card, or notification for surfacing customer-bearing fields (`E-Mail`, `Name`, `Phone No.`, audit fields, `User ID`). The permission system governs visibility; the page does not. diff --git a/microsoft/knowledge/privacy/pages-displaying-permitted-data-is-not-a-privacy-concern.md b/microsoft/knowledge/privacy/pages-displaying-permitted-data-is-not-a-privacy-concern.md deleted file mode 100644 index e28a0d6..0000000 --- a/microsoft/knowledge/privacy/pages-displaying-permitted-data-is-not-a-privacy-concern.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [page, display, permission, authenticated, false-positive] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Pages displaying data to permitted users are not a privacy concern - -## Description - -Every page in Business Central displays data to an authenticated user who holds the permissions required to see it. The permission system — table permissions, entitlements, field-level restrictions where configured — is the access-control boundary. Flagging a page for showing customer emails, names, addresses, document numbers, or system audit fields treats display as a leak when it is the product's intended function. - -## Best Practice - -Privacy review of pages is about data classification on the source table and about consent on outgoing integrations reached through page actions. Displaying business data to a user with permission to view it is correct behaviour, including on API pages that are gated by the same permission model. - -## Anti Pattern - -Reporting "customer email is shown on the page" or "user ID visible in the list" as privacy findings. The finding does not reflect a privacy regression and redirects the author toward hiding data that the permitted user is entitled to see. The same logic produces noise on Confirm, Message, and Notification that surface business identifiers. diff --git a/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.bad.al b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.bad.al new file mode 100644 index 0000000..6308674 --- /dev/null +++ b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.bad.al @@ -0,0 +1,13 @@ +codeunit 50217 "Privacy Sample Consent Bad" +{ + procedure SendDataToExternalService(Customer: Record Customer) + var + HttpClient: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + begin + Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}', + Customer."E-Mail", Customer.Name)); + HttpClient.Post('https://api.externalservice.com/sync', Content, Response); + end; +} diff --git a/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.good.al b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.good.al new file mode 100644 index 0000000..0ac939c --- /dev/null +++ b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.good.al @@ -0,0 +1,22 @@ +codeunit 50216 "Privacy Sample Consent Good" +{ + procedure SendDataToExternalService(Customer: Record Customer) + var + PrivacyNotice: Codeunit "Privacy Notice"; + PrivacyNoticeRegistrations: Codeunit "Privacy Notice Registrations"; + HttpClient: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + PrivacyConsentRequiredErr: Label 'Privacy notice consent is required for this integration.'; + begin + if PrivacyNotice.GetPrivacyNoticeApprovalState( + PrivacyNoticeRegistrations.GetExchangePrivacyNoticeId()) + <> "Privacy Notice Approval State"::Agreed + then + Error(PrivacyConsentRequiredErr); + + Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}', + Customer."E-Mail", Customer.Name)); + HttpClient.Post('https://api.externalservice.com/sync', Content, Response); + end; +} diff --git a/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md new file mode 100644 index 0000000..a064792 --- /dev/null +++ b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [privacy-notice, consent, http-client, outgoing-request, external-service, getprivacynoticeapprovalstate] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Outgoing requests to external services require a Privacy Notice consent check + +## Description + +Business Central ships a built-in Privacy Notice framework that the admin uses to grant or withhold per-integration consent for sending data to external services. The relevant API surface is `Codeunit "Privacy Notice"` (consent checks via `GetPrivacyNoticeApprovalState()`), `Codeunit "Privacy Notice Registrations"` (well-known notice IDs for integrations such as Exchange, OneDrive, Teams), and the `Enum "Privacy Notice Approval State"` with values `Agreed`, `Disagreed`, and `Not Set`. The admin UI is the **Privacy Notices Status** page. The compliance concern in code review is therefore not that personal data is included in an outgoing HTTP body — that is normal business functionality — but that the code path issuing the request contains no `PrivacyNotice.GetPrivacyNoticeApprovalState(...)` check. + +## Best Practice + +Before issuing an outgoing HTTP request to an external service, verify `PrivacyNotice.GetPrivacyNoticeApprovalState() = "Privacy Notice Approval State"::Agreed`. The check does not have to live next to the `HttpClient.Post` call — it can sit anywhere upstream in the same code path (for example in the page's `OnOpenPage`, in a wizard step, or in a setup action) as long as no execution path reaches the request without passing through it. + +See sample: `privacy-notice-consent-for-external-data-transfer.good.al`. + +## Anti Pattern + +A `procedure SendDataToExternalService(...)` that posts customer data to an external endpoint with no `PrivacyNotice.GetPrivacyNoticeApprovalState` anywhere upstream. The same anti-pattern applies in reverse: removing an existing privacy-notice check from code that still issues the external call. + +See sample: `privacy-notice-consent-for-external-data-transfer.bad.al`. diff --git a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al new file mode 100644 index 0000000..d8a20f5 --- /dev/null +++ b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al @@ -0,0 +1,11 @@ +codeunit 50218 "Privacy Sample Register Integration" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Privacy Notice Registrations", 'OnRegisterPrivacyNotices', '', false, false)] + local procedure OnRegisterPrivacyNotices(var TempPrivacyNotice: Record "Privacy Notice" temporary) + var + PrivacyNotice: Codeunit "Privacy Notice"; + begin + PrivacyNotice.CreatePrivacyNoticeForIntegration( + 'My External Sync', 'External Customer Sync Service'); + end; +} diff --git a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md new file mode 100644 index 0000000..40779e9 --- /dev/null +++ b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [privacy-notice-registrations, integration, register, exchange, onedrive, teams, notice-id] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Register every new external integration with `Privacy Notice Registrations` + +## Description + +`Codeunit "Privacy Notice Registrations"` is the registry of integrations whose consent state the platform tracks. Built-in integrations such as Exchange, OneDrive and Teams already have notice IDs exposed via accessor methods on this codeunit (`GetExchangePrivacyNoticeId`, etc.); a new integration introduced by an extension must add itself to the registry so that the admin can grant or withhold consent on the **Privacy Notices Status** page. Without registration, there is nothing for `Codeunit "Privacy Notice"` to return an approval state for — the call cannot meaningfully gate the outbound request. + +## Best Practice + +When introducing a new outbound integration: pick a stable notice ID, register it via `Privacy Notice Registrations`, and then gate every outbound call with `PrivacyNotice.GetPrivacyNoticeApprovalState()` as described in `privacy-notice-consent-for-external-data-transfer.md`. + +See sample: `register-integration-in-privacy-notice-registrations.good.al`. + +## Anti Pattern + +Shipping a new outbound integration without registering it. Even if the code calls `GetPrivacyNoticeApprovalState`, the admin has no surface to express consent — the integration is effectively unmanaged from a privacy-notice standpoint. diff --git a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.bad.al b/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.bad.al deleted file mode 100644 index 0349acf..0000000 --- a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50905 "Privacy Sample Consent Bad" -{ - procedure SyncToPartner(var Customer: Record Customer) - var - Client: HttpClient; - Content: HttpContent; - Response: HttpResponseMessage; - begin - // Customer email and name sent externally with no Privacy Notice check - // anywhere in the reachable code path. - Content.WriteFrom(Customer."E-Mail"); - Client.Post('https://partner.example.com/sync', Content, Response); - end; -} diff --git a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.good.al b/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.good.al deleted file mode 100644 index c3aec56..0000000 --- a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.good.al +++ /dev/null @@ -1,20 +0,0 @@ -codeunit 50904 "Privacy Sample Consent Good" -{ - procedure SyncToPartner(var Customer: Record Customer) - var - PrivacyNotice: Codeunit "Privacy Notice"; - Client: HttpClient; - Content: HttpContent; - Response: HttpResponseMessage; - PartnerNoticeIdTok: Label 'Contoso-PartnerSync', Locked = true; - ConsentRequiredErr: Label 'Consent is required before syncing to the external partner.'; - begin - if PrivacyNotice.GetPrivacyNoticeApprovalState(PartnerNoticeIdTok, false) <> - "Privacy Notice Approval State"::Agreed - then - Error(ConsentRequiredErr); - - Content.WriteFrom(Customer."No."); - Client.Post('https://partner.example.com/sync', Content, Response); - end; -} diff --git a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.md b/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.md deleted file mode 100644 index 2ac058a..0000000 --- a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [privacy-notice, consent, gdpr, httpclient, outgoing-request] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Check Privacy Notice consent before outgoing requests with customer data - -## Description - -Business Central ships a Privacy Notice framework for user consent to third-party integrations. When code sends personal data (emails, names, addresses) to an external service, the concern is not whether the data itself is compliant — the product handles that — but whether the code path has verified the user has agreed to the integration. Missing consent checks on new or modified outgoing paths is the privacy issue to flag; the presence of PII in the payload is not. - -## Best Practice - -Before an outgoing HttpClient call that carries customer data, verify consent via `Codeunit "Privacy Notice".GetPrivacyNoticeApprovalState()` for the integration's registered notice id. The check may live upstream (page OnOpenPage, wizard step) as long as every path that reaches the external call passes through it. Register new integrations via `Codeunit "Privacy Notice Registrations"`. - -See sample: `require-privacy-notice-consent-before-outgoing-requests.good.al`. - -## Anti Pattern - -Adding or modifying an outgoing integration and sending customer data without any `Privacy Notice` check in the reachable code path. Removing an existing consent check from an integration that still sends data externally falls in the same category. - -See sample: `require-privacy-notice-consent-before-outgoing-requests.bad.al`. diff --git a/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md b/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md index 4e8c99c..6820704 100644 --- a/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md +++ b/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md @@ -1,22 +1,22 @@ --- bc-version: [all] domain: privacy -keywords: [tobeclassified, dataclassification, release, gdpr, placeholder] +keywords: [tobeclassified, data-classification, release, appsource, development] technologies: [al] countries: [w1] application-area: [all] --- -# Resolve ToBeClassified before release +# Resolve every `ToBeClassified` before release ## Description -`DataClassification = ToBeClassified` is a development marker, not a releasable privacy state. It tells reviewers and tooling that the field still needs classification work. Shipping it prevents data-subject, retention, and telemetry tooling from making a correct decision about the field. +`DataClassification = ToBeClassified` is the sentinel value the AL compiler accepts while a developer has not yet decided what a new field actually stores. It exists for the development phase only and must be resolved to a real classification (`CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `AccountData`, `OrganizationIdentifiableInformation` or `SystemMetadata`) before the code ships. A released field left at `ToBeClassified` tells the platform "we have not classified this data" — which means GDPR data-subject requests, telemetry and audit reports cannot reason about it. ## Best Practice -Replace every `ToBeClassified` value with the narrowest accurate classification before the PR ships to customers. If the field inherits a correct table-level DataClassification, remove the placeholder rather than leaving a field-level `ToBeClassified` override. +Treat `ToBeClassified` as a TODO marker that fails release readiness. Sweep new table objects and table extensions for it before submitting a build for publication. If the right classification is genuinely unclear, decide between `CustomerContent` and `EndUserIdentifiableInformation` from the data's content, not from convenience. ## Anti Pattern -Treating ToBeClassified as a safe default because the field is new or because the final classification is uncertain. Uncertainty should bias toward a stronger classification, not toward an unresolved placeholder. +Leaving `ToBeClassified` in a shipped extension. Reviewers who treat the value as "I'll figure it out later" ship a field whose privacy posture is undefined for every customer that installs the app. diff --git a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.bad.al b/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.bad.al deleted file mode 100644 index 4ff958b..0000000 --- a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.bad.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 50907 "Privacy Sample LastErr Bad" -{ - procedure LogFailure() - var - CategoryTok: Label 'Sync', Locked = true; - FailureTxt: Label 'Operation failed: %1', Comment = '%1 = last error text'; - begin - // GetLastErrorText(true) carries the call stack and field values from - // the failing context. Declared as SystemMetadata but the payload is CustomerContent. - Session.LogMessage( - '0000ABC', StrSubstNo(FailureTxt, GetLastErrorText(true)), - Verbosity::Error, - DataClassification::SystemMetadata, - TelemetryScope::ExtensionPublisher, 'Category', CategoryTok); - end; -} diff --git a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.good.al b/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.good.al deleted file mode 100644 index 1c3d8fc..0000000 --- a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.good.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50906 "Privacy Sample LastErr Good" -{ - procedure LogFailure() - var - CategoryTok: Label 'Sync', Locked = true; - GenericMsgTxt: Label 'Sync operation failed. See extended log for details.'; - begin - // Generic message, no GetLastErrorText. Detail goes to an internal log - // the telemetry pipeline does not receive. - Session.LogMessage( - '0000ABC', GenericMsgTxt, Verbosity::Error, - DataClassification::SystemMetadata, - TelemetryScope::ExtensionPublisher, 'Category', CategoryTok); - end; -} diff --git a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.md b/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.md deleted file mode 100644 index 496c0ac..0000000 --- a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [getlasterrortext, telemetry, callstack, dataclassification, pii] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Sanitize GetLastErrorText before sending to telemetry - -## Description - -`GetLastErrorText` and `GetLastErrorCallStack` return strings built from the failing call site's data — field values, record keys, customer names, filenames. Logging either to telemetry with `DataClassification::SystemMetadata` misstates the content: the actual values are CustomerContent or worse. The true classification is not always SystemMetadata, and silently mislabelling a CustomerContent payload as system data is the specific privacy regression to avoid. - -## Best Practice - -Log a generic error message and either omit GetLastErrorText entirely or classify the telemetry call as `DataClassification::CustomerContent`. Prefer `GetLastErrorText(false)` to exclude the call stack when the text is needed but the stack is not. When in doubt, log a generic summary and persist the detailed error separately in a restricted-access log the telemetry pipeline does not receive. - -See sample: `sanitize-getlasterrortext-before-telemetry.good.al`. - -## Anti Pattern - -`Session.LogMessage(..., StrSubstNo('Operation failed: %1', GetLastErrorText(true)), ..., DataClassification::SystemMetadata, ...)` — the classification is wrong for the payload, and the call stack typically carries customer data from the failing operation into the telemetry stream. - -See sample: `sanitize-getlasterrortext-before-telemetry.bad.al`. diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al new file mode 100644 index 0000000..e895d7c --- /dev/null +++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al @@ -0,0 +1,7 @@ +codeunit 50211 "Privacy Sample LogMessage Bad" +{ + procedure LogCompleted() + begin + Session.LogMessage('0003', 'Operation completed', Verbosity::Normal); + end; +} diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al new file mode 100644 index 0000000..d3353ec --- /dev/null +++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al @@ -0,0 +1,8 @@ +codeunit 50210 "Privacy Sample LogMessage Good" +{ + procedure LogCompleted() + begin + Session.LogMessage('0003', 'Operation completed', Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher); + end; +} diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md new file mode 100644 index 0000000..67381f7 --- /dev/null +++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [session-logmessage, telemetry, data-classification, verbosity, telemetry-scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every `Session.LogMessage` call must specify `DataClassification` + +## Description + +`Session.LogMessage` writes a record to the telemetry pipeline. The platform requires the call to carry an explicit `DataClassification` argument so that the entry can be routed and retained correctly downstream — telemetry consumers, GDPR exports, and Application Insights dashboards all rely on it. The compiler accepts overloads without the parameter (the two-argument and three-argument shapes that omit it), but for any telemetry that ships to customers, the `DataClassification`-bearing overload is the correct one. + +## Best Practice + +Use the overload that takes `Verbosity`, `DataClassification`, and `TelemetryScope`. For payload-free operational telemetry that does not embed customer data, `DataClassification::SystemMetadata` is the right value. Choose `TelemetryScope::ExtensionPublisher` for telemetry meant for the publishing partner only; `TelemetryScope::All` also forwards to the customer's tenant telemetry. + +See sample: `session-logmessage-requires-dataclassification.good.al`. + +## Anti Pattern + +Calling `Session.LogMessage('0003', 'Operation completed', Verbosity::Normal)` — the overload omits `DataClassification` and leaves the platform without the information needed to classify the entry. Detection signal: a `Session.LogMessage` call whose argument list ends at `Verbosity`. + +See sample: `session-logmessage-requires-dataclassification.bad.al`. diff --git a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.bad.al b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.bad.al deleted file mode 100644 index f2eb191..0000000 --- a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.bad.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 50913 "Privacy Sample Telemetry Bad" -{ - procedure LogProcessed(var Customer: Record Customer) - var - CategoryTok: Label 'CustomerProcessing', Locked = true; - MsgTemplateTxt: Label 'Processed customer %1', Comment = '%1 = customer name'; - begin - // Declared SystemMetadata; payload is CustomerContent. The message is - // opaque text once built; the pipeline cannot redact. - Session.LogMessage( - '0000001', StrSubstNo(MsgTemplateTxt, Customer.Name), - Verbosity::Normal, - DataClassification::SystemMetadata, - TelemetryScope::All, 'Category', CategoryTok); - end; -} diff --git a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.good.al b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.good.al deleted file mode 100644 index 233e11d..0000000 --- a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.good.al +++ /dev/null @@ -1,17 +0,0 @@ -codeunit 50912 "Privacy Sample Telemetry Good" -{ - procedure LogProcessed(var Customer: Record Customer) - var - CategoryTok: Label 'CustomerProcessing', Locked = true; - ProcessedMsgTxt: Label 'Customer record processed.'; - begin - // Generic message. Business identifier in a custom dimension, - // never a free-text personal name. - Session.LogMessage( - '0000001', ProcessedMsgTxt, Verbosity::Normal, - DataClassification::SystemMetadata, - TelemetryScope::ExtensionPublisher, - 'Category', CategoryTok, - 'CustomerNo', Customer."No."); - end; -} diff --git a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md deleted file mode 100644 index cf8d257..0000000 --- a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [telemetry, session-logmessage, dataclassification, dimensions, pii] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Specify DataClassification on every telemetry call and keep PII out of the message - -## Description - -`Session.LogMessage` accepts a DataClassification parameter that governs how the platform handles the logged content in the telemetry pipeline. Omitting it is a schema violation the platform cannot repair later. Embedding personal data — emails, names, phone numbers, addresses, filenames of user uploads — in the message string also defeats classification, because the pipeline sees opaque text and cannot selectively redact. The same privacy boundary applies to other telemetry surfaces such as `Codeunit "Feature Telemetry"` custom dimensions. - -## Best Practice - -Pass DataClassification explicitly on every Session.LogMessage call. Keep the message a generic, non-identifying sentence and place structured values in custom dimensions where the classification applies per key. Business identifiers (Customer No., Document No., Vendor No.) are acceptable as dimensions; free-text personal data is not. - -See sample: `specify-dataclassification-on-every-telemetry-call.good.al`. - -## Anti Pattern - -`Session.LogMessage('0001', StrSubstNo('Customer %1 processed', Customer.Name), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All)` — the declared classification is SystemMetadata but the message carries CustomerContent. The payload is logged with the wrong tag; downstream consumers treat it as safe when it is not. - -See sample: `specify-dataclassification-on-every-telemetry-call.bad.al`. diff --git a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.bad.al b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.bad.al deleted file mode 100644 index f162f6a..0000000 --- a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50901 "Privacy Sample StrSubstNo Bad" -{ - procedure FailCustomer(var Customer: Record Customer) - var - ErrorMsg: Text; - begin - // Platform receives a plain Text string. It cannot inspect fields, - // cannot classify, cannot strip. The email and address reach telemetry. - ErrorMsg := StrSubstNo( - 'Customer %1 (%2) at %3 has invalid data', - Customer.Name, Customer."E-Mail", Customer.Address); - Error(ErrorMsg); - end; -} diff --git a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.good.al b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.good.al deleted file mode 100644 index 3ced013..0000000 --- a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.good.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50900 "Privacy Sample StrSubstNo Good" -{ - procedure FailCustomer(var Customer: Record Customer) - var - CustomerDataInvalidErr: Label 'Customer %1 has invalid data.', Comment = '%1 = Customer No.'; - begin - // Platform sees the Label and the field reference. It inspects the - // field's DataClassification and handles telemetry correctly. - Error(CustomerDataInvalidErr, Customer."No."); - end; -} diff --git a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md deleted file mode 100644 index f70c00e..0000000 --- a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [strsubstno, error, telemetry, dataclassification, pii] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Pre-building Error text with StrSubstNo defeats platform PII stripping - -## Description - -Error messages are captured by platform telemetry. When Error receives a format template and substitution arguments directly (`Error('... %1 ...', Value)`), the platform can classify and strip sensitive values before telemetry is written. This is true whether the arguments are record fields, local variables, function results, or other expressions. When the caller pre-builds the message with StrSubstNo and then passes the resulting Text to Error, the platform sees a plain string with no argument context and logs the whole thing verbatim — any PII already baked in is exported to telemetry. - -## Best Practice - -Pass the template and substitution arguments directly to Error. Declare the template as a Label with a Comment describing each placeholder. Do not flag direct Error substitution merely because an argument may contain a customer name, email address, or phone number; the platform intercepts those arguments before telemetry. - -See sample: `strsubstno-prebuild-breaks-error-telemetry-classification.good.al`. - -## Anti Pattern - -Assigning the output of StrSubstNo to a Text variable and passing that variable to Error. Every substituted value is now part of an opaque string; the platform cannot classify it and logs everything. - -See sample: `strsubstno-prebuild-breaks-error-telemetry-classification.bad.al`. diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al new file mode 100644 index 0000000..e1e5808 --- /dev/null +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al @@ -0,0 +1,16 @@ +table 50202 "System Configuration Log" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Changed By"; Code[50]) { } + field(3; "Change Description"; Text[250]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md new file mode 100644 index 0000000..bf457e9 --- /dev/null +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-classification, table-level, inheritance, override, cascading] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Table-level DataClassification cascades to every field unless overridden + +## Description + +`DataClassification` may be set at the table level. When it is, every field in the table inherits that classification and individual fields do not need their own `DataClassification` property. The cascade is the platform's intended way of classifying tables whose fields are homogeneous — for example, a system configuration log whose every column is `SystemMetadata`. A field only needs its own classification when its content genuinely differs from the table's default and the inherited value would be wrong. + +## Best Practice + +Set `DataClassification` once at the table level whenever every field in the table shares the same classification. Omit field-level `DataClassification` properties in that case. Override only on the specific fields whose data class differs from the table's — for example, a `SystemMetadata` audit table that nonetheless captures a `CustomerContent` value somewhere. + +See sample: `table-level-data-classification-cascades.good.al`. + +## Anti Pattern + +Flagging individual fields for "missing `DataClassification`" when the table declares one — the inheritance is the correct, intentional pattern. The mirror anti-pattern is repeating the same `DataClassification` on every field of a table that already declares it at the table level; the property is redundant and adds nothing the platform did not already know. diff --git a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.bad.al b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.bad.al new file mode 100644 index 0000000..ab698a1 --- /dev/null +++ b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.bad.al @@ -0,0 +1,7 @@ +codeunit 50227 "Sec Sample HtmlEncode Bad" +{ + procedure BuildWelcomeHtml(UserName: Text): Text + begin + exit('
Welcome ' + UserName + '!
'); + end; +} diff --git a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.good.al b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.good.al new file mode 100644 index 0000000..6da1911 --- /dev/null +++ b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.good.al @@ -0,0 +1,19 @@ +codeunit 50226 "Sec Sample HtmlEncode Good" +{ + procedure BuildWelcomeHtml(UserName: Text): Text + var + SafeName: Text; + begin + SafeName := EncodeHtml(UserName); + exit('
Welcome ' + SafeName + '!
'); + end; + + local procedure EncodeHtml(Value: Text): Text + begin + Value := Value.Replace('&', '&'); + Value := Value.Replace('<', '<'); + Value := Value.Replace('>', '>'); + Value := Value.Replace('"', '"'); + exit(Value); + end; +} diff --git a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md new file mode 100644 index 0000000..7441712 --- /dev/null +++ b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [html, xss, encoding, htmlencode, injection, email] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL has no built-in HtmlEncode — encode HTML output by hand or avoid it + +## Description + +AL does not ship a built-in `HtmlEncode` (or equivalent) function. Code that builds an HTML fragment — an email body, a report header, a chart label rendered as HTML — by concatenating record-field values into a string is therefore unencoded by default, and any `<`, `>`, `&`, or `"` in the user content is interpreted as markup by the receiving renderer. The result is cross-site scripting in the recipient's mail client, browser, or report viewer. The absence of a built-in encoder is non-obvious to anyone used to platforms where `HtmlEncode` is a one-liner. + +## Best Practice + +Replace the four characters by hand before concatenating user content into HTML: `&` → `&` first, then `<` → `<`, `>` → `>`, `"` → `"`. Centralize the substitution in one helper so every HTML producer in the extension uses the same encoder. Better still, do not build raw HTML at all — use a structured format (JSON for an API payload, a report layout for a printed document) and let the renderer do the encoding. See sample: `al-has-no-built-in-htmlencode.good.al`. + +## Anti Pattern + +`HtmlContent := '
Welcome ' + UserName + '!
'` — any record-field value or user input concatenated directly into an HTML string. Reviewers should flag any string concatenation whose right-hand operand is a field, a parameter, or any non-literal value, and whose surrounding context contains HTML tags (`<`, ` Contributions welcome — open a PR to refine or extend this article. - -## Description - -SecretStrSubstNo is the SecretText analogue of StrSubstNo. The template is a regular string literal; substitution arguments may be SecretText; the return value is SecretText. Intermediate results of the composition are never materialized as plaintext. - -## Best Practice - -Format SecretText templates with SecretStrSubstNo. This is the correct primitive for building authorization headers, secret URIs, and any other formatted string that embeds a SecretText. Provide the static parts of the template as a regular string literal; only the substitutions carry the secret value. - -See sample: `compose-secrets-with-secretstrsubstno.good.al`. - -## Anti Pattern - -Using StrSubstNo (or plain string concatenation) on a plain-Text token to build an authorization header. The result is a Text containing the secret in plaintext, visible in the debugger, inspectable in snapshot debug sessions, and captured by any logging the caller does not control. SecretText should have been used end-to-end. - -See sample: `compose-secrets-with-secretstrsubstno.bad.al`. - diff --git a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.bad.al b/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.bad.al deleted file mode 100644 index 7659ea9..0000000 --- a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.bad.al +++ /dev/null @@ -1,16 +0,0 @@ -tableextension 51303 "Sec Sample VTR Bad" extends "Sales Header" -{ - fields - { - // Editable user input with validation suppressed and no fallback check. - // The user can type any string; downstream Get against Customer will fail - // or return a wrong row. - field(50102; "Customer No."; Code[20]) - { - Caption = 'Customer no.'; - DataClassification = CustomerContent; - TableRelation = Customer."No."; - ValidateTableRelation = false; - } - } -} diff --git a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.good.al b/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.good.al deleted file mode 100644 index 9b76762..0000000 --- a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.good.al +++ /dev/null @@ -1,24 +0,0 @@ -tableextension 51302 "Sec Sample VTR Good" extends "Sales Header" -{ - fields - { - // User-editable field keeps ValidateTableRelation default (true). - field(50100; "External Customer Ref"; Code[50]) - { - Caption = 'External customer reference'; - DataClassification = CustomerContent; - TableRelation = Customer."No."; - } - - // System-controlled field: validation bypass is acceptable because - // the value is populated by controlled upstream code, not the user. - field(50101; "System Batch Id"; Code[20]) - { - Caption = 'System batch ID'; - DataClassification = SystemMetadata; - TableRelation = "Job Queue Entry".ID; - ValidateTableRelation = false; - Editable = false; - } - } -} diff --git a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.md b/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.md deleted file mode 100644 index 0cec0fd..0000000 --- a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [validatetablerelation, user-input, lookup, integrity, validation] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not set ValidateTableRelation = false on fields that accept user input - -## Description - -`TableRelation` on a field tells the platform that the value must exist as a primary key in the related table. `ValidateTableRelation = false` suppresses that check at validation time. On system-populated fields — values the code sets from a controlled source and never displays as editable — the suppression is acceptable because the integrity guarantee comes from the upstream writer. On a field the user types into (a page field, an import column, an API payload), disabling the validation means any value at all can be written: a non-existent customer number, a typo, a deliberate bad value. The table no longer enforces the relation, and downstream code that Gets the related row with an unguarded lookup breaks. - -## Best Practice - -Leave `ValidateTableRelation = true` (the default) on any field the user can set. When the default would produce unhelpful behaviour — a transient lookup that does not yet exist at validation time, a reference that uses a non-primary-key column — handle it with a targeted OnValidate trigger that performs the semantic check explicitly. Use `ValidateTableRelation = false` only when the field is genuinely system-controlled and the writer has already validated the reference. - -See sample: `do-not-disable-validatetablerelation-on-user-input.good.al`. - -## Anti Pattern - -A `Customer No.` field on an editable page with `TableRelation = Customer."No."` and `ValidateTableRelation = false` and no OnValidate fallback. The user can type any string; the platform accepts it; a later Get against Customer fails or returns the wrong row. - -See sample: `do-not-disable-validatetablerelation-on-user-input.bad.al`. diff --git a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.bad.al b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.bad.al deleted file mode 100644 index c111d18..0000000 --- a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.bad.al +++ /dev/null @@ -1,19 +0,0 @@ -codeunit 50229 "Sec Sample EventPublisher Bad" -{ - [IntegrationEvent(false, false)] - local procedure OnBeforeExportCustomer(CustomerNo: Code[20]; ExportCredentials: SecretText; var AllowExport: Boolean) - begin - end; - - procedure ExportCustomer(CustomerNo: Code[20]; Credentials: SecretText) - var - AllowExport: Boolean; - begin - // Any subscriber on the tenant receives the credentials and - // can flip AllowExport := true to bypass the publisher's check. - OnBeforeExportCustomer(CustomerNo, Credentials, AllowExport); - if not AllowExport then - exit; - // ... perform export - end; -} diff --git a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.good.al b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.good.al deleted file mode 100644 index 8d7962c..0000000 --- a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.good.al +++ /dev/null @@ -1,23 +0,0 @@ -codeunit 50228 "Sec Sample EventPublisher Good" -{ - [IntegrationEvent(false, false)] - local procedure OnBeforeExportCustomer(CustomerNo: Code[20]) - begin - end; - - procedure ExportCustomer(CustomerNo: Code[20]) - begin - if not CallerIsAuthorizedToExport(CustomerNo) then - Error('You are not authorized to export this customer.'); - - OnBeforeExportCustomer(CustomerNo); - // ... perform export using credentials owned by this codeunit - end; - - local procedure CallerIsAuthorizedToExport(CustomerNo: Code[20]): Boolean - begin - // Authorization decision stays inside the publisher. Subscribers - // receive only the customer number and cannot influence the - // decision. - end; -} diff --git a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md deleted file mode 100644 index e5e3e08..0000000 --- a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [event, publisher, extensibility, var-parameter] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not expose sensitive data in event publishers - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Events in AL are extensibility contracts. Every subscriber — third-party, internal, or installed after the fact — receives the full set of event parameters. Parameters that carry secrets, pre-authorization state, or variables the publisher relies on for access control effectively become public, and var-parameters can be mutated by a subscriber to alter publisher behaviour. - -## Best Practice - -Design event signatures to carry only the data a subscriber legitimately needs. Do not pass SecretText, credential material, or flags the publisher depends on for access control. Guard variables such as `HasAccess`, `SkipValidation`, or `CanExport` must not be `var` parameters on an OnBefore event; notify subscribers after the internal check with value parameters they cannot mutate. - -See sample: `do-not-expose-sensitive-data-in-event-publishers.good.al`. - -## Anti Pattern - -An OnBeforeElevateAccess publisher that exposes `var CanAccess: Boolean` or `var SkipValidation: Boolean` — any subscriber installed on the tenant can flip it to true and bypass the check. Or a publisher that passes a SecretText parameter it obtained internally, handing it to every subscriber. - -See sample: `do-not-expose-sensitive-data-in-event-publishers.bad.al`. - diff --git a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.bad.al b/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.bad.al deleted file mode 100644 index 3ed2bff..0000000 --- a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 51301 "Sec Sample EnvGuid Bad" -{ - procedure GetTenantId(): Text - begin - // Tenant GUID hardcoded. Extension works in one environment, fails in every other. - exit('{12345678-1234-1234-1234-123456789012}'); - end; - - procedure GetAadApplicationId(): Text - begin - // AAD application GUID hardcoded. Same problem, surfaces as an authentication error. - exit('{87654321-4321-4321-4321-210987654321}'); - end; -} diff --git a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.good.al b/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.good.al deleted file mode 100644 index e19a771..0000000 --- a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.good.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 51300 "Sec Sample EnvGuid Good" -{ - procedure KnownSystemId(): Guid - begin - // Stable across tenants and versions — Base Application Id. - exit('{437dbf0e-84ff-417a-965d-ed2bb9650972}'); - end; - - procedure GetTenantId(): Text - var - EnvironmentInformation: Codeunit "Environment Information"; - begin - // Environment-specific values are retrieved at runtime. - exit(EnvironmentInformation.GetTenantId()); - end; -} diff --git a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.md b/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.md deleted file mode 100644 index 7fb3775..0000000 --- a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [guid, tenant-id, aad, environment, hardcoded] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Hardcoded GUIDs are only safe for well-known system identifiers - -## Description - -AL code sometimes carries hardcoded GUIDs. Some are platform-defined, stable across tenants and versions, and legitimately constant — the Base Application's ApplicationId (`{437dbf0e-84ff-417a-965d-ed2bb9650972}`) is the canonical example. Others identify a specific tenant, a specific Azure Active Directory application, or a specific environment; these look identical at the source-code level but are environment-bound and break the moment the extension is deployed anywhere else. Shipping an environment-specific GUID as a constant effectively locks the extension to one environment, and the failure mode in other tenants is usually an authentication error with no code-level signal pointing at the literal. - -## Best Practice - -Hardcoded GUIDs are acceptable for well-known system identifiers that are stable across environments — document the identifier with a comment that names what it refers to. For tenant IDs, AAD application IDs, API subscription IDs, and any value that varies by deployment, retrieve at runtime from IsolatedStorage, configuration tables, or the platform APIs that expose the current tenant context. - -See sample: `do-not-hardcode-environment-specific-guids.good.al`. - -## Anti Pattern - -`TenantId := '{12345678-1234-1234-1234-123456789012}';` or `AadApplicationId := '{87654321-...}';` inline in a codeunit. The extension authenticates in one environment and fails in every other; debugging starts from an AAD error message that does not mention the literal. - -See sample: `do-not-hardcode-environment-specific-guids.bad.al`. diff --git a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.bad.al b/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.bad.al deleted file mode 100644 index 3cdde58..0000000 --- a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.bad.al +++ /dev/null @@ -1,6 +0,0 @@ -permissionset 50201 "Sec Sample Full Access" -{ - Assignable = true; - Caption = 'Full Access (sample anti-pattern)'; - Permissions = tabledata * = RIMD; -} diff --git a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.good.al b/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.good.al deleted file mode 100644 index d7ad6bd..0000000 --- a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.good.al +++ /dev/null @@ -1,9 +0,0 @@ -permissionset 50200 "Sec Sample Sales Order Entry" -{ - Assignable = true; - Caption = 'Sales Order Entry (sample)'; - Permissions = - tabledata "Sales Header" = RIM, - tabledata "Sales Line" = RIMD, - tabledata Customer = R; -} diff --git a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md b/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md deleted file mode 100644 index 444ceed..0000000 --- a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [permissionset, least-privilege, rimd, tabledata] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Follow least privilege in permission sets - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Permission sets define the tabledata and object rights granted to every user or role assigned to them. A permission set that grants RIMD on tabledata * hands every caller full control over every table the extension exposes, which is never the shape of access any real role requires. Over-broad permission sets are a persistent source of privilege-escalation risk: once assigned, they are rarely audited. - -## Best Practice - -Enumerate the specific tabledata objects a role needs and grant only the letters (R, I, M, D) that role genuinely uses. A sales order-entry role typically needs RIM on Sales Header, RIMD on Sales Line, and R on Customer — not blanket RIMD. Permission sets SHOULD be granular and role-shaped; a single permission set that covers every role in an extension is a design smell. - -See sample: `follow-least-privilege-in-permission-sets.good.al`. - -## Anti Pattern - -Granting `tabledata * = RIMD` (or any wildcard with I, M, or D) in a permission set. This bypasses any meaningful separation of duties the extension could enforce and gives unreviewed code paths the ability to insert, modify, and delete on any table. - -See sample: `follow-least-privilege-in-permission-sets.bad.al`. - diff --git a/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.bad.al b/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.bad.al new file mode 100644 index 0000000..8a8c025 --- /dev/null +++ b/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.bad.al @@ -0,0 +1,10 @@ +codeunit 50234 "Sec Sample LastErrText" +{ + procedure RunWithCapture(var ErrorLog: Record "Integration Log") + begin + if not Codeunit.Run(Codeunit::"My Worker") then begin + ErrorLog."Error Text" := CopyStr(GetLastErrorText(), 1, MaxStrLen(ErrorLog."Error Text")); + ErrorLog.Insert(true); + end; + end; +} diff --git a/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md b/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md new file mode 100644 index 0000000..4e9da1d --- /dev/null +++ b/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [getlasterrortext, error-text, classification, privacy, review-scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Storing GetLastErrorText() in table fields is a privacy finding, not a security finding + +## Description + +It is tempting to flag any code that calls `GetLastErrorText()` and writes the result into a table field (or displays it to end users) as a security issue, on the assumption that the error text might leak credentials or system internals. In Business Central, that pattern is treated as a **privacy** concern instead: AL `Error` text frequently contains customer content (record keys, field values, document numbers) rather than infrastructure details, and the appropriate review owner is the privacy/DataClassification reviewer. A security reviewer should not raise a finding for `GetLastErrorText()` storage on the grounds that it might expose secrets; that risk is covered elsewhere by the rules that prevent secrets from appearing in error messages in the first place (see `secrettext-for-credentials.md`). + +## Best Practice + +When auditing AL changes for security, ignore patterns where `GetLastErrorText()` is captured into a table or shown to users — leave those to the privacy review. Security findings on error text should be limited to the construction of the `Error()` call itself: secrets, paths, or technical internals being interpolated into the error before it is raised. See sample: `getlasterrortext-storage-is-privacy-not-security.bad.al` for the pattern that is *not* a security finding. + +## Anti Pattern + +Filing a security finding such as "GetLastErrorText() stored in field — potential information disclosure" against AL code that captures an error for later inspection. The finding is in the wrong domain and crowds out the actual security signal. The mirror anti-pattern is silencing genuine `Error('... %1 ...', SecretValue)` constructions on the grounds that "error text is privacy" — those *are* security findings because they create the leak, regardless of where the text ends up afterwards. diff --git a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.bad.al b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.bad.al new file mode 100644 index 0000000..37fe2a6 --- /dev/null +++ b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.bad.al @@ -0,0 +1,4 @@ +permissionset 50204 "Sec Sample Report Runner Bad" +{ + Permissions = tabledata "G/L Entry" = RIMD; +} diff --git a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al new file mode 100644 index 0000000..9fef5e4 --- /dev/null +++ b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al @@ -0,0 +1,4 @@ +permissionset 50203 "Sec Sample Report Runner" +{ + Permissions = tabledata "G/L Entry" = ri; +} diff --git a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md new file mode 100644 index 0000000..206d211 --- /dev/null +++ b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [permissionset, indirect-permissions, ri, ii, mi, di, code-mediated] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use indirect permissions when access must be code-mediated + +## Description + +In a `permissionset`, uppercase letters (`R`, `I`, `M`, `D`) grant **direct** permissions: the assignee can read, insert, modify, or delete the table data through any UI or API surface. Lowercase letters (`r`, `i`, `m`, `d`) grant **indirect** permissions: the operation is allowed only when it is invoked from AL code that itself holds the corresponding direct permission. Indirect permissions let a role consume privileged tables through controlled procedures (a report, a posting routine) without giving users a way to read or change those tables outside the intended code path. + +## Best Practice + +Use indirect permissions (`ri`, `ii`, `mi`, `di`) when a role needs access to a sensitive table only through a specific codeunit or report — for example, a "Report Runner" role that reads `G/L Entry` only via published reports. Pair the indirect grant with the codeunit or report that mediates access; that object's own permissions (or InherentPermissions) supply the direct rights. Document why indirect permissions are required in the permission set or in the consuming object's comments. See sample: `indirect-permissions-for-elevated-access.good.al`. + +## Anti Pattern + +Granting `RIMD` on a sensitive table when the role only needs to view it through a report — for example `tabledata "G/L Entry" = RIMD` on a "Report Runner" role. Users assigned that role can now query and modify ledger entries directly through any client that respects the permission, bypassing the report entirely. Reviewers should look for uppercase grants on system-of-record tables (G/L Entry, ledger entries, posted documents) where the consuming code path is clearly read-through-report or read-through-API. See sample: `indirect-permissions-for-elevated-access.bad.al`. diff --git a/microsoft/knowledge/security/inherent-permissions-minimal-grant.bad.al b/microsoft/knowledge/security/inherent-permissions-minimal-grant.bad.al new file mode 100644 index 0000000..a75b336 --- /dev/null +++ b/microsoft/knowledge/security/inherent-permissions-minimal-grant.bad.al @@ -0,0 +1,20 @@ +codeunit 50206 "Sec Sample Inherent Bad" +{ + [InherentPermissions(PermissionObjectType::TableData, Database::"Sales Header", 'RIMD')] + [InherentEntitlements(Entitlement::"Dynamics 365 Business Central Premium")] + procedure GetCustomerName(CustomerNo: Code[20]): Text + var + Customer: Record Customer; + begin + if Customer.Get(CustomerNo) then + exit(Customer.Name); + end; + + [InherentEntitlements(Entitlement::"Dynamics 365 Business Central Premium")] + procedure CheckItemExists(ItemNo: Code[20]): Boolean + var + Item: Record Item; + begin + exit(Item.Get(ItemNo)); + end; +} diff --git a/microsoft/knowledge/security/inherent-permissions-minimal-grant.good.al b/microsoft/knowledge/security/inherent-permissions-minimal-grant.good.al new file mode 100644 index 0000000..e6d903e --- /dev/null +++ b/microsoft/knowledge/security/inherent-permissions-minimal-grant.good.al @@ -0,0 +1,19 @@ +codeunit 50205 "Sec Sample Inherent Good" +{ + [InherentPermissions(PermissionObjectType::TableData, Database::Customer, 'r')] + procedure GetCustomerName(CustomerNo: Code[20]): Text + var + Customer: Record Customer; + begin + if Customer.Get(CustomerNo) then + exit(Customer.Name); + end; + + [InherentPermissions(PermissionObjectType::TableData, Database::Item, 'r')] + procedure CheckItemExists(ItemNo: Code[20]): Boolean + var + Item: Record Item; + begin + exit(Item.Get(ItemNo)); + end; +} diff --git a/microsoft/knowledge/security/inherent-permissions-minimal-grant.md b/microsoft/knowledge/security/inherent-permissions-minimal-grant.md new file mode 100644 index 0000000..41ba2a5 --- /dev/null +++ b/microsoft/knowledge/security/inherent-permissions-minimal-grant.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [inherentpermissions, inherententitlements, attribute, least-privilege, procedure] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Grant the minimum InherentPermissions a procedure needs + +## Description + +`[InherentPermissions(PermissionObjectType::..., ...)]` and `[InherentEntitlements(Entitlement::...)]` are method-level attributes that let a procedure perform an operation on the listed object even when the caller's permission set does not allow it. They effectively elevate the caller for the duration of the procedure. The grant therefore needs to be as narrow as the procedure's actual work — both in object scope (the specific table) and in operation (`'r'` versus `'RIMD'`). Overly broad inherent permissions silently expand the attack surface of every codeunit that calls the procedure. + +## Best Practice + +Match the inherent permission to the procedure's body: a procedure that only reads `Customer.Name` declares `[InherentPermissions(PermissionObjectType::TableData, Database::Customer, 'r')]`, not `'RIMD'`. Pick the inherent entitlement that matches the lowest tier the procedure should run under — do not require Premium for a procedure that performs an Essential-tier check. See sample: `inherent-permissions-minimal-grant.good.al`. + +## Anti Pattern + +Declaring `[InherentPermissions(..., 'RIMD')]` on a read-only procedure (`GetCustomerName`), or `[InherentEntitlements(Entitlement::"Dynamics 365 Business Central Premium")]` on a procedure that performs a simple existence check. Reviewers should compare the attribute's permission letters against what the procedure body actually does and flag any grant broader than the operations performed. See sample: `inherent-permissions-minimal-grant.bad.al`. diff --git a/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.bad.al b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.bad.al new file mode 100644 index 0000000..79da066 --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.bad.al @@ -0,0 +1,7 @@ +codeunit 50229 "Sec Sample EventSecret Bad" +{ + [IntegrationEvent(false, false)] + local procedure OnBeforeSendRequest(var ApiKey: Text; var Password: Text; var RequestUrl: Text) + begin + end; +} diff --git a/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.good.al b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.good.al new file mode 100644 index 0000000..b30372b --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.good.al @@ -0,0 +1,7 @@ +codeunit 50228 "Sec Sample EventSecret Good" +{ + [IntegrationEvent(false, false)] + local procedure OnBeforeSendRequest(var RequestPayload: JsonObject; var IsHandled: Boolean) + begin + end; +} diff --git a/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md new file mode 100644 index 0000000..1c51f8b --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [integrationevent, eventsubscriber, secrets, credentials, publisher] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not pass credentials or secrets through IntegrationEvent parameters + +## Description + +`[IntegrationEvent]` publishes a hook that any extension can subscribe to. Every parameter of the event signature is visible to every subscriber — including `var` parameters, which subscribers can both read and modify. A publisher that includes an API key, password, bearer token, or other secret in the event signature hands that secret to every subscriber on the tenant, including subscribers in extensions the publisher has no relationship with. There is no permission or partner-only filter that limits who may subscribe. + +## Best Practice + +Restrict event payloads to the non-sensitive context a subscriber legitimately needs: the business record being processed (a `Customer`), the operation being performed, an `IsHandled` flag that lets a subscriber skip the default behaviour, and a mutable payload object whose contents the publisher controls. Authentication is handled by the publisher before or after the event, never inside the parameters. See sample: `integrationevent-must-not-expose-secrets.good.al`. + +## Anti Pattern + +`[IntegrationEvent(false, false)] procedure OnBeforeSendRequest(var ApiKey: Text; var Password: Text; var RequestUrl: Text)` — any extension on the tenant can subscribe, read `ApiKey` and `Password`, and persist them elsewhere. Reviewers should flag any event parameter whose name or type suggests a secret (`ApiKey`, `Token`, `Password`, `Secret`, `Credential`, `SecretText` — even `SecretText` should not flow through an event surface). See sample: `integrationevent-must-not-expose-secrets.bad.al`. diff --git a/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.bad.al b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.bad.al new file mode 100644 index 0000000..ee1b8cf --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.bad.al @@ -0,0 +1,19 @@ +codeunit 50231 "Sec Sample EventGuard Bad" +{ + procedure CheckPermissionsForTable(TableNo: Integer) + var + HasAccess: Boolean; + SkipValidation: Boolean; + begin + OnBeforeCheckPermissions(HasAccess, SkipValidation, TableNo); + if SkipValidation then + exit; + if not HasAccess then + Error('Access denied.'); + end; + + [IntegrationEvent(false, false)] + local procedure OnBeforeCheckPermissions(var HasAccess: Boolean; var SkipValidation: Boolean; TableNo: Integer) + begin + end; +} diff --git a/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.good.al b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.good.al new file mode 100644 index 0000000..9d17d9e --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.good.al @@ -0,0 +1,22 @@ +codeunit 50230 "Sec Sample EventGuard Good" +{ + procedure CheckPermissionsForTable(TableNo: Integer) + var + HasAccess: Boolean; + begin + HasAccess := PerformInternalCheck(TableNo); + if not HasAccess then + Error('Access denied.'); + OnAfterCheckPermissions(TableNo, HasAccess); + end; + + local procedure PerformInternalCheck(TableNo: Integer): Boolean + begin + exit(true); + end; + + [IntegrationEvent(false, false)] + local procedure OnAfterCheckPermissions(TableNo: Integer; HasAccess: Boolean) + begin + end; +} diff --git a/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md new file mode 100644 index 0000000..3429e80 --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [integrationevent, var, guard, ishandled, bypass, security-check] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not expose security guards as `var` parameters on IntegrationEvent + +## Description + +A `var` parameter on an `[IntegrationEvent]` is a mutable hook: any subscriber can overwrite the value and the publisher will see the new value when control returns. That is the right shape for "let an extension contribute to a payload"; it is the wrong shape for "let an extension confirm a security decision". A `var HasAccess: Boolean` or `var SkipValidation: Boolean` lets any subscriber on the tenant flip the result of the publisher's permission check to `true` (or set "skip" to `true`) before the publisher reads it. The publisher's check becomes advisory, which is the same as not having a check. + +## Best Practice + +Keep the security decision inside the publisher, where it is not bypassable. Fire an `OnAfter*` informational event after the check completes, with the result passed by value (not `var`) so subscribers can react — log, audit, surface a warning — but cannot rewrite the outcome. When subscribers legitimately need to add their own checks, expose an `OnAfterCheckPermissions(...)` that can only tighten access (e.g., a subscriber can `Error()`), never loosen it. See sample: `integrationevent-var-parameter-bypasses-security-guards.good.al`. + +## Anti Pattern + +`OnBeforeCheckPermissions(var HasAccess: Boolean; var SkipValidation: Boolean; TableNo: Integer)`, followed in the caller by `if SkipValidation then exit;`. Any subscriber sets `SkipValidation := true` and the check is gone. Reviewers should flag any `IntegrationEvent` whose signature contains a `var Boolean` whose name reads like a security decision (`HasAccess`, `IsAllowed`, `SkipValidation`, `BypassCheck`, `IsAuthorized`). See sample: `integrationevent-var-parameter-bypasses-security-guards.bad.al`. diff --git a/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.bad.al b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.bad.al new file mode 100644 index 0000000..84297bb --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.bad.al @@ -0,0 +1,16 @@ +codeunit 50216 "Sec Sample IsoStorage Bad" +{ + procedure GetApiKey(): Text + var + ApiKey: Text; + begin + if IsolatedStorage.Contains('ApiKey', DataScope::Module) then + IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey); + exit(ApiKey); + end; + + procedure SetApiKey(NewKey: Text) + begin + IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.good.al b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.good.al new file mode 100644 index 0000000..22e279b --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.good.al @@ -0,0 +1,15 @@ +codeunit 50215 "Sec Sample IsoStorage Good" +{ + local procedure GetApiKey(var ApiKey: SecretText): Boolean + begin + if not IsolatedStorage.Contains('ApiKey', DataScope::Module) then + exit(false); + IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey); + exit(true); + end; + + internal procedure SetApiKey(NewKey: Text) + begin + IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md new file mode 100644 index 0000000..cbf5d5d --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [isolatedstorage, local, internal, public, getter, setter, encapsulation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Procedures that read or write IsolatedStorage must not be public + +## Description + +`IsolatedStorage` partitions its data by extension: values written by one extension are unreadable to another. That guarantee assumes the owning extension does not voluntarily expose its storage through a public API. A `public` procedure on a codeunit that calls `IsolatedStorage.Get`, `IsolatedStorage.Set`, `IsolatedStorage.SetEncrypted`, `IsolatedStorage.Contains`, or `IsolatedStorage.Delete` defeats the isolation: any other extension on the same tenant can call that procedure and obtain (or overwrite) the secret. The platform's per-extension boundary becomes a per-procedure boundary, and there is no per-procedure boundary. + +## Best Practice + +Mark every procedure that touches `IsolatedStorage` as `local` (visible only inside its containing object) or `internal` (visible only inside the owning extension). Provide consumers with a narrow, intent-specific API — for example, "send notification to configured webhook" rather than "give me the webhook secret." See sample: `isolatedstorage-access-must-be-local-or-internal.good.al`. + +## Anti Pattern + +A public `GetApiKey()` returning the stored value, or a public `SetApiKey(NewKey: Text)` that calls `IsolatedStorage.SetEncrypted`. Both turn the extension into a confused deputy that hands out (or accepts overwrites of) its own secrets on behalf of any caller on the tenant. Reviewers should flag any procedure whose body references `IsolatedStorage` and whose declaration omits `local` or `internal`. See sample: `isolatedstorage-access-must-be-local-or-internal.bad.al`. diff --git a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al new file mode 100644 index 0000000..60efe31 --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al @@ -0,0 +1,15 @@ +codeunit 50220 "Sec Sample DataScope Bad" +{ + internal procedure StoreCompanyWebhook(WebhookUrl: Text) + begin + IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Module); + end; + + local procedure ReadCompanyWebhook(var WebhookUrl: SecretText): Boolean + begin + if not IsolatedStorage.Contains('WebhookUrl', DataScope::Company) then + exit(false); + IsolatedStorage.Get('WebhookUrl', DataScope::Company, WebhookUrl); + exit(true); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al new file mode 100644 index 0000000..397635f --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al @@ -0,0 +1,20 @@ +codeunit 50219 "Sec Sample DataScope Good" +{ + internal procedure StoreTenantApiKey(ApiKey: Text) + begin + IsolatedStorage.SetEncrypted('TenantApiKey', ApiKey, DataScope::Module); + end; + + internal procedure StoreCompanyWebhook(WebhookUrl: Text) + begin + IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Company); + end; + + local procedure ReadCompanyWebhook(var WebhookUrl: SecretText): Boolean + begin + if not IsolatedStorage.Contains('WebhookUrl', DataScope::Company) then + exit(false); + IsolatedStorage.Get('WebhookUrl', DataScope::Company, WebhookUrl); + exit(true); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md new file mode 100644 index 0000000..711895d --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [isolatedstorage, datascope, module, company, user, scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pick the right IsolatedStorage DataScope for the secret's lifetime + +## Description + +`IsolatedStorage` read and write methods take a `DataScope` parameter that decides which slice of storage the value belongs to. The choice is not a stylistic one — it changes which callers, in which company and under which user, can read the value back. Two scopes cover the common cases for app-level secrets: `DataScope::Module` stores the value once for the whole extension, isolated to that extension on the tenant — the right scope for app-specific secrets such as a global API key or service account. `DataScope::Company` stores the value per company, so each company on the tenant has its own slot — the right scope for company-specific secrets such as a per-company webhook URL or a per-company integration token. A per-user scope also exists for values that belong to an individual user. + +## Best Practice + +Choose `Module` when the secret is the same for every company and every user under the extension (a single tenant-wide API key). Choose `Company` when each company has its own integration credentials. Choose the user scope only when the secret is genuinely per-user. Use the same `DataScope` value on `Set`/`SetEncrypted`, `Get`, `Contains`, and `Delete` for the same key — mixing scopes for the same logical secret produces silent "not found" results. See sample: `isolatedstorage-datascope-module-vs-company.good.al`. + +## Anti Pattern + +Defaulting every call to `DataScope::Module` regardless of intent — storing a per-company webhook URL under `Module` means every company on the tenant shares the same URL. Or the inverse: storing a tenant-wide API key under `Company` means each company-switch effectively loses the key. Reviewers should look for cross-method inconsistency (`Set` under `Module`, `Get` under `Company`) and for scope choices that contradict the value's documented lifetime. See sample: `isolatedstorage-datascope-module-vs-company.bad.al`. diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.bad.al b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.bad.al new file mode 100644 index 0000000..dc75430 --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.bad.al @@ -0,0 +1,7 @@ +codeunit 50218 "Sec Sample SetEncrypted Bad" +{ + internal procedure StoreApiKey(ApiKeyValue: Text) + begin + IsolatedStorage.Set('ApiKey', ApiKeyValue, DataScope::Module); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al new file mode 100644 index 0000000..215055c --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al @@ -0,0 +1,17 @@ +codeunit 50217 "Sec Sample SetEncrypted Good" +{ + internal procedure StoreApiKey(ApiKeyValue: Text) + begin + if StrLen(ApiKeyValue) > 200 then + Error('API key too long for encrypted storage'); + IsolatedStorage.SetEncrypted('ApiKey', ApiKeyValue, DataScope::Module); + end; + + local procedure ReadApiKey(var ApiKey: SecretText): Boolean + begin + if not IsolatedStorage.Contains('ApiKey', DataScope::Module) then + exit(false); + IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey); + exit(true); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md new file mode 100644 index 0000000..4e4f6b9 --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [isolatedstorage, setencrypted, encryption, secret, storage] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefer IsolatedStorage.SetEncrypted over Set for sensitive values + +## Description + +`IsolatedStorage` exposes two write entry points: `Set` stores the value as-is, and `SetEncrypted` stores it encrypted at rest. Both are scoped per extension, but only `SetEncrypted` adds the additional protection that the value is not readable from the underlying storage by anything that bypasses the AL `IsolatedStorage` API. The choice between them is by intent: configuration that is not sensitive (a user preference, a default flag) can use `Set`; anything that would harm the tenant if leaked — API keys, tokens, connection strings, OAuth client secrets — uses `SetEncrypted`. + +## Best Practice + +Use `IsolatedStorage.SetEncrypted` for every value that meets the definition of a secret. Pair it with the matching retrieval pattern: `IsolatedStorage.Contains` to test for presence and `IsolatedStorage.Get` (preferably with a `SecretText` destination) to read. Constrain the input length before storing — long values can exceed the encrypted-storage size limit and the write will fail at runtime. See sample: `isolatedstorage-setencrypted-for-sensitive-values.good.al`. + +## Anti Pattern + +`IsolatedStorage.Set('ApiKey', ApiKeyValue, DataScope::Module)` — the key is now sitting in storage unencrypted, and any future incident that exposes the underlying storage exposes the key. Reviewers should flag any `IsolatedStorage.Set` whose key name or surrounding context suggests a secret (`ApiKey`, `Token`, `Password`, `Secret`, `ClientSecret`). See sample: `isolatedstorage-setencrypted-for-sensitive-values.bad.al`. diff --git a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.good.al b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.good.al deleted file mode 100644 index ec005d1..0000000 --- a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50242 "Sec Sample RecordRef Good" -{ - internal procedure ArchiveRecord(RecId: RecordId) - var - RecRef: RecordRef; - begin - RecRef.Open(RecId.TableNo); - RecRef.Get(RecId); - RecRef.Delete(); - RecRef.Close(); - end; -} diff --git a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.md b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.md deleted file mode 100644 index 609374f..0000000 --- a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [recordref, recordid, table-no, scope, inherentpermissions] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Keep caller-driven RecordRef.Open procedures non-public - -## Description - -A codeunit can hold permissions or `InherentPermissions` that its callers do not have. If it exposes a public procedure that accepts a table number or RecordId and calls `RecordRef.Open`, another extension can call that procedure to make the privileged codeunit open tables on its behalf. That turns a generic helper into a permission-bypass surface, especially for system tables. - -## Best Practice - -Procedures that call `RecordRef.Open` with a caller-provided table number must be `local`, `internal`, or `[Scope('OnPrem')]`. If the procedure truly must be public in SaaS, validate the table number against a narrow allowlist before opening the RecordRef. - -See sample: `keep-recordref-open-callers-non-public.good.al`. - -## Anti Pattern - -A public helper such as `ArchiveRecord(RecId: RecordId)` that opens `RecId.TableNo` and then reads, modifies, or deletes through RecordRef. The helper compiles, but it lets untrusted callers choose which table the privileged code opens. - -See sample: `keep-recordref-open-callers-non-public.bad.al`. diff --git a/microsoft/knowledge/security/never-hardcode-secrets-in-al.bad.al b/microsoft/knowledge/security/never-hardcode-secrets-in-al.bad.al deleted file mode 100644 index 841a809..0000000 --- a/microsoft/knowledge/security/never-hardcode-secrets-in-al.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50207 "Sec Sample HardcodedSecret Bad" -{ - var - HardcodedApiKeyLbl: Label 'sk-live-1234567890abcdef', Locked = true; - - procedure GetApiKey(): Text - begin - exit(HardcodedApiKeyLbl); - end; -} diff --git a/microsoft/knowledge/security/never-hardcode-secrets-in-al.good.al b/microsoft/knowledge/security/never-hardcode-secrets-in-al.good.al deleted file mode 100644 index 835bbbd..0000000 --- a/microsoft/knowledge/security/never-hardcode-secrets-in-al.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50206 "Sec Sample HardcodedSecret Good" -{ - procedure GetApiKey() ApiKey: SecretText - var - StoredValue: SecretText; - begin - if IsolatedStorage.Contains('ApiKey', DataScope::Module) then - if IsolatedStorage.Get('ApiKey', DataScope::Module, StoredValue) then - exit(StoredValue); - Error('API key is not configured.'); - end; -} diff --git a/microsoft/knowledge/security/never-hardcode-secrets-in-al.md b/microsoft/knowledge/security/never-hardcode-secrets-in-al.md deleted file mode 100644 index 6822b4a..0000000 --- a/microsoft/knowledge/security/never-hardcode-secrets-in-al.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [secrets, credentials, hardcoded, label, apikey] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Never hardcode secrets in AL - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -A secret embedded in AL source — API key, password, connection string, token — lives forever: in the app package, in source control history, in every debugger session that sees the assignment, and in any log that captures the containing variable. Rotation is effectively impossible without a new release, and the blast radius covers every tenant the extension is installed in. - -## Best Practice - -Retrieve secrets at runtime from a protected store: Azure Key Vault for production workloads (see prefer-azure-key-vault-for-production-secrets) or IsolatedStorage for tenant-local encrypted values (see use-isolated-storage-for-module-and-company-secrets). Carry the retrieved value in a SecretText variable end-to-end (see use-secrettext-for-credentials). - -See sample: `never-hardcode-secrets-in-al.good.al`. - -## Anti Pattern - -Assigning a secret literal to a Text, Code, or Label variable (including labels marked as constants). The secret is now part of the compiled app and indistinguishable from non-sensitive content to callers and tools. - -See sample: `never-hardcode-secrets-in-al.bad.al`. - diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al new file mode 100644 index 0000000..a22b60f --- /dev/null +++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al @@ -0,0 +1,19 @@ +codeunit 50214 "Sec Sample NonDebug Bad" +{ + procedure BuildConnectionString(ApiKey: SecretText): Text + begin + exit('Server=db.example.com;Key=' + ApiKey.Unwrap()); + end; + + procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) + var + ResponseText: Text; + JsonObject: JsonObject; + JsonToken: JsonToken; + begin + Response.Content.ReadAs(ResponseText); + JsonObject.ReadFrom(ResponseText); + JsonObject.Get('access_token', JsonToken); + SessionToken := JsonToken.AsValue().AsText(); + end; +} diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al new file mode 100644 index 0000000..421e9bd --- /dev/null +++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al @@ -0,0 +1,21 @@ +codeunit 50213 "Sec Sample NonDebug Good" +{ + [NonDebuggable] + procedure BuildConnectionString(ApiKey: SecretText): Text + begin + exit('Server=db.example.com;Key=' + ApiKey.Unwrap()); + end; + + [NonDebuggable] + procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) + var + ResponseText: Text; + JsonObject: JsonObject; + JsonToken: JsonToken; + begin + Response.Content.ReadAs(ResponseText); + JsonObject.ReadFrom(ResponseText); + JsonObject.Get('access_token', JsonToken); + SessionToken := JsonToken.AsValue().AsText(); + end; +} diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md new file mode 100644 index 0000000..b214977 --- /dev/null +++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [nondebuggable, attribute, secrettext, unwrap, debugger] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Mark procedures that call SecretText.Unwrap() as [NonDebuggable] + +## Description + +`SecretText` transit — assignment, parameter passing, and return values — is auto-protected: the debugger sees a redacted placeholder, not the value. The protection ends the moment code calls `.Unwrap()`, which converts the `SecretText` back to plain `Text`. From that point on, the local variable holding the result is visible in the debugger like any other `Text`. The `[NonDebuggable]` attribute marks a procedure so that none of its locals or parameters are visible to the debugger during execution, which is exactly what is needed for any procedure that performs an `Unwrap()` or that otherwise materializes a secret as `Text` (for example, while parsing a JSON response to extract an access token). + +## Best Practice + +Apply `[NonDebuggable]` to any procedure whose body calls `.Unwrap()` on a `SecretText`, and to any procedure that constructs a `SecretText` from a `Text` source (such as a procedure that reads a JSON response body and converts the resulting `Text` into a `SecretText` for the caller). Keep the unwrap window as small as possible — ideally a single one-line helper that hands the unwrapped value straight to the consuming API. See sample: `nondebuggable-required-when-unwrapping-secrettext.good.al`. + +## Anti Pattern + +Calling `ApiKey.Unwrap()` inside a procedure that is not marked `[NonDebuggable]`. The unwrapped value is now an ordinary `Text` local and the debugger will display it, defeating the purpose of using `SecretText` in the first place. Reviewers should flag any `Unwrap()` call in a procedure that lacks the attribute, and any procedure that parses a credential out of a response (`access_token`, `id_token`, `client_secret`) without the attribute. See sample: `nondebuggable-required-when-unwrapping-secrettext.bad.al`. diff --git a/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.bad.al b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.bad.al new file mode 100644 index 0000000..054892d --- /dev/null +++ b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.bad.al @@ -0,0 +1,10 @@ +permissionset 50201 "Sec Sample Full Access" +{ + Permissions = tabledata * = RIMD; +} + +permissionset 50202 "Sec Sample Basic User" +{ + Permissions = table * = X, + tabledata * = R; +} diff --git a/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.good.al b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.good.al new file mode 100644 index 0000000..5a3c8a3 --- /dev/null +++ b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.good.al @@ -0,0 +1,8 @@ +permissionset 50200 "Sec Sample Sales Entry" +{ + Permissions = tabledata "Sales Header" = RIM, + tabledata "Sales Line" = RIMD, + tabledata Customer = R, + table "Sales Header" = X, + table "Sales Line" = X; +} diff --git a/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md new file mode 100644 index 0000000..20332b2 --- /dev/null +++ b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [permissionset, wildcard, rimd, tabledata, least-privilege] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid wildcard grants in permission sets + +## Description + +A `permissionset` object can grant access object-by-object or with the `*` wildcard. Wildcard grants — `tabledata * = RIMD` (Read/Insert/Modify/Delete on every table) and `table * = X` (Execute on every table object) — collapse the principle of least privilege into a single line and are almost never what the author intended. The grant binds for the lifetime of the permission set wherever it is assigned, including indirectly via role assignment. Permission sets should be granular and role-specific, enumerating only the objects the role actually needs. + +## Best Practice + +Enumerate each `tabledata` and each `table` entry explicitly. Grant only the letters required: `R` for read-only consumers, `RIM` for editors that do not delete, `RIMD` only for owners of the data. When a role needs Execute on objects, list those objects rather than using `table *`. See sample: `permission-set-avoid-wildcard-grants.good.al`. + +## Anti Pattern + +`Permissions = tabledata * = RIMD;` and `Permissions = table * = X, tabledata * = R;` — both grant access to objects the role's author never inspected, and the grant silently broadens every time a new table ships in the platform or in another extension. Reviewers should flag any `*` on the left-hand side of a `tabledata` or `table` entry. See sample: `permission-set-avoid-wildcard-grants.bad.al`. diff --git a/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md b/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md deleted file mode 100644 index 382f8e8..0000000 --- a/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [keyvault, azure, secrets, rotation, audit] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Prefer Azure Key Vault for production secrets - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Azure Key Vault is an external secret store that supports central management, rotation, and access auditing. The Business Central system application exposes integration APIs that retrieve Key Vault secrets at runtime. IsolatedStorage, by contrast, is a per-tenant local encrypted store with no central rotation or audit story. - -## Best Practice - -For production workloads that require secret rotation, access auditing, and separation between secret custodians and app developers, Azure Key Vault SHOULD be the store of record. Retrieve secrets into a SecretText variable on demand, cache only as long as the call requires, and never persist the retrieved plaintext anywhere the extension does not control. IsolatedStorage MAY be used when a per-tenant local encrypted store is all that is required. - -## Anti Pattern - -Treating IsolatedStorage as the long-term home for secrets in a multi-tenant production extension where secret rotation, central revocation, or access auditing are required. - diff --git a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.bad.al b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.bad.al similarity index 83% rename from microsoft/knowledge/security/keep-recordref-open-callers-non-public.bad.al rename to microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.bad.al index 3081231..d0977e4 100644 --- a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.bad.al +++ b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.bad.al @@ -1,4 +1,4 @@ -codeunit 50243 "Sec Sample RecordRef Bad" +codeunit 50233 "Sec Sample RecRef Bad" { procedure ArchiveRecord(RecId: RecordId) var diff --git a/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.good.al b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.good.al new file mode 100644 index 0000000..9bd4bec --- /dev/null +++ b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.good.al @@ -0,0 +1,29 @@ +codeunit 50232 "Sec Sample RecRef Good" +{ + internal procedure ArchiveRecord(RecId: RecordId) + var + RecRef: RecordRef; + begin + RecRef.Open(RecId.TableNo); + RecRef.Get(RecId); + RecRef.Delete(); + RecRef.Close(); + end; + + procedure ArchiveAllowedRecord(RecId: RecordId) + var + RecRef: RecordRef; + begin + if not IsAllowedTable(RecId.TableNo) then + Error('Operation not permitted on this table.'); + RecRef.Open(RecId.TableNo); + RecRef.Get(RecId); + RecRef.Delete(); + RecRef.Close(); + end; + + local procedure IsAllowedTable(TableNo: Integer): Boolean + begin + exit(TableNo in [Database::Customer, Database::Vendor]); + end; +} diff --git a/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md new file mode 100644 index 0000000..c84e15a --- /dev/null +++ b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [recordref, open, public, system-table, scope-onprem, confused-deputy, saas] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Procedures that RecordRef.Open a caller-provided table must not be public + +## Description + +When a codeunit holds permission to system tables — directly, via a permission set granted at install, or via `[InherentPermissions]` — and exposes a public procedure that accepts a table number (or a `RecordId`, from which the table number is derived) and calls `RecordRef.Open` on it, the procedure becomes a confused deputy. Any other extension on the same tenant can invoke the procedure with the table number of a system table the calling extension does not own permissions for and obtain access to its rows through the wrapper. This is especially acute in SaaS: an on-premises-style extension that holds broad permissions can be exploited by a co-tenant extension that calls its public surface. + +## Best Practice + +Mark such procedures `local` (callable only inside the containing object), `internal` (callable only inside the owning extension), or `[Scope('OnPrem')]` (not callable from SaaS extensions). If the procedure must be public, validate the table number against an allow-list before `RecordRef.Open` — `if not IsAllowedTable(RecId.TableNo) then Error(...)` — so the caller cannot specify an arbitrary table. See sample: `recordref-open-with-caller-table-must-not-be-public.good.al`. + +## Anti Pattern + +`procedure ArchiveRecord(RecId: RecordId)` (public by default) whose body calls `RecRef.Open(RecId.TableNo)` and then reads, modifies, or deletes the record. Reviewers should flag any procedure that is public (no `local`/`internal`/`[Scope('OnPrem')]`), takes a `RecordId`, `Integer` table number, or `Variant` as a parameter, and calls `RecordRef.Open` with that parameter — unless an allow-list check on the table number precedes the open. See sample: `recordref-open-with-caller-table-must-not-be-public.bad.al`. diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al new file mode 100644 index 0000000..84dda45 --- /dev/null +++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al @@ -0,0 +1,12 @@ +codeunit 50212 "Sec Sample SecretSubst Bad" +{ + procedure BuildAuthHeader(Token: SecretText): Text + begin + exit(StrSubstNo('Bearer %1', Token.Unwrap())); + end; + + procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): Text + begin + exit(BaseUrl + '?key=' + ApiKey.Unwrap()); + end; +} diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al new file mode 100644 index 0000000..f550025 --- /dev/null +++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al @@ -0,0 +1,12 @@ +codeunit 50211 "Sec Sample SecretSubst Good" +{ + procedure BuildAuthHeader(Token: SecretText): SecretText + begin + exit(SecretStrSubstNo('Bearer %1', Token)); + end; + + procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): SecretText + begin + exit(SecretStrSubstNo('%1?key=%2', BaseUrl, ApiKey)); + end; +} diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md new file mode 100644 index 0000000..6e315f7 --- /dev/null +++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [secretstrsubstno, secrettext, strsubstno, format, compose] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use SecretStrSubstNo to compose strings that contain secrets + +## Description + +`SecretStrSubstNo` is the secret-preserving counterpart of `StrSubstNo`. It accepts a format string and arguments (any of which may be `SecretText`) and returns a `SecretText` — the substitution happens without ever materializing the result as plain `Text`. It is the right tool whenever a secret needs to be embedded in a larger string: an `Authorization: Bearer ` header value, a URI that includes an API key as a query parameter, or any other interpolation that combines a `SecretText` with surrounding context. + +## Best Practice + +Compose every secret-bearing string through `SecretStrSubstNo` and keep the result as `SecretText` end-to-end. Pass the result to the `SecretText` overload of the consumer — `HttpClient.SetSecretRequestUri`, `HttpHeaders.Add`, or `HttpContent.WriteFrom`. See sample: `secretstrsubstno-for-composing-secrets.good.al`. + +## Anti Pattern + +Calling `StrSubstNo('Bearer %1', Token.Unwrap())` to build the header value, or concatenating `'Bearer ' + Token.Unwrap()`. Both produce a plain `Text` containing the secret, which is then visible in the debugger and in any subsequent log or trace. Reviewers should flag any `Unwrap()` whose result is fed into `StrSubstNo` or used in `+` concatenation — `SecretStrSubstNo` removes the need for either. See sample: `secretstrsubstno-for-composing-secrets.bad.al`. diff --git a/microsoft/knowledge/security/secrettext-for-credentials.bad.al b/microsoft/knowledge/security/secrettext-for-credentials.bad.al new file mode 100644 index 0000000..5b5ac23 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-for-credentials.bad.al @@ -0,0 +1,22 @@ +codeunit 50208 "Sec Sample SecretText Bad" +{ + procedure CallExternalApi() + var + ApiKey: Text; + BearerToken: Text; + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + begin + ApiKey := GetApiKey(); + BearerToken := GetAccessToken(); + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('Authorization', 'Bearer ' + BearerToken); + Headers.Add('X-Api-Key', ApiKey); + HttpClient.Get('https://api.example.com/data', Response); + end; + + local procedure GetApiKey(): Text begin end; + + local procedure GetAccessToken(): Text begin end; +} diff --git a/microsoft/knowledge/security/secrettext-for-credentials.good.al b/microsoft/knowledge/security/secrettext-for-credentials.good.al new file mode 100644 index 0000000..d98f127 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-for-credentials.good.al @@ -0,0 +1,16 @@ +codeunit 50207 "Sec Sample SecretText Good" +{ + procedure CallExternalApi() + var + ApiKey: SecretText; + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + begin + if IsolatedStorage.Contains('ApiKey', DataScope::Module) then + IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey); + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('X-Api-Key', ApiKey); + HttpClient.Get('https://api.example.com/data', Response); + end; +} diff --git a/microsoft/knowledge/security/secrettext-for-credentials.md b/microsoft/knowledge/security/secrettext-for-credentials.md new file mode 100644 index 0000000..17fec22 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-for-credentials.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [secrettext, credentials, api-key, token, debugger, unwrap] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use SecretText for credentials, API keys, and tokens + +## Description + +`SecretText` is the AL data type for values that should never appear in a debugger session, in a log, or in a variable watch. The compiler enforces two guarantees: a string literal cannot be assigned directly to a `SecretText` variable, and a `SecretText` cannot be assigned back to a `Text` or `Code` without an explicit `Unwrap` call. Together these prevent the two common accidents — embedding a secret in source code, and quietly converting a secret to plain text where the debugger can read it. Use `SecretText` for parameters, return values, and local variables that carry API keys, tokens, passwords, connection strings, or any other value an attacker with debugger access should not see. + +## Best Practice + +Declare credential-carrying parameters and variables as `SecretText` from the call site that retrieves the secret all the way to the call site that consumes it (typically an `HttpClient` header or URI). Never round-trip through `Text` — every conversion is a potential exposure point. Retrieve secrets from `IsolatedStorage` with the `SecretText` overload of `Get` rather than the `Text` overload. See sample: `secrettext-for-credentials.good.al`. + +## Anti Pattern + +Holding a credential in a `Text` variable (`BearerToken: Text`), concatenating it into a header, then passing it to `HttpClient`. The token is visible in the debugger and in any error that prints the variable, and the compiler offers no help because the type was wrong from the start. Reviewers should flag any local or parameter named like a secret (`ApiKey`, `Token`, `Password`, `ClientSecret`) whose type is `Text` or `Code`. See sample: `secrettext-for-credentials.bad.al`. diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.bad.al b/microsoft/knowledge/security/secrettext-with-httpclient.bad.al new file mode 100644 index 0000000..6ddb883 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-with-httpclient.bad.al @@ -0,0 +1,23 @@ +codeunit 50210 "Sec Sample SecretHttp Bad" +{ + procedure CallApiWithSecretInUri(ApiKey: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + RequestUri: Text; + begin + RequestUri := 'https://api.example.com/data?key=' + ApiKey.Unwrap(); + HttpClient.Get(RequestUri, Response); + end; + + procedure CallApiWithBearer(BearerToken: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + begin + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('Authorization', 'Bearer ' + BearerToken.Unwrap()); + HttpClient.Get('https://api.example.com/data', Response); + end; +} diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.good.al b/microsoft/knowledge/security/secrettext-with-httpclient.good.al new file mode 100644 index 0000000..50f0e31 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-with-httpclient.good.al @@ -0,0 +1,28 @@ +codeunit 50209 "Sec Sample SecretHttp Good" +{ + procedure CallApiWithSecretUri(ApiKey: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + SecretUri: SecretText; + begin + SecretUri := SecretStrSubstNo('https://api.example.com/data?key=%1', ApiKey); + HttpClient.SetSecretRequestUri(SecretUri); + HttpClient.Get('', Response); + end; + + procedure CallApiWithBearer(BearerToken: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + AuthHeader: SecretText; + begin + AuthHeader := SecretStrSubstNo('Bearer %1', BearerToken); + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('Authorization', AuthHeader); + if not Headers.ContainsSecret('Authorization') then + Error('Authorization header missing'); + HttpClient.Get('https://api.example.com/data', Response); + end; +} diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.md b/microsoft/knowledge/security/secrettext-with-httpclient.md new file mode 100644 index 0000000..f8be895 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-with-httpclient.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [secrettext, httpclient, setsecretrequesturi, containssecret, headers, http] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use the SecretText-aware HttpClient surface for secrets in requests + +## Description + +`HttpClient` and its companion types expose a parallel surface that accepts `SecretText` instead of `Text`, so that secret URIs, secret headers, and secret request bodies never round-trip through plain text. The key entry points are: `HttpClient.SetSecretRequestUri()` for URIs that contain secrets (the subsequent `Get`/`Post` is then called with an empty string); `HttpHeaders.Add()` overload that accepts a `SecretText` value for authorization headers; `HttpHeaders.ContainsSecret()` to test whether a secret header is present (the plain `Contains()` returns false for secret headers); `HttpContent.WriteFrom()` and `HttpContent.ReadAs()` overloads that accept and produce `SecretText` for request and response bodies that carry credentials. + +## Best Practice + +When the URI contains a secret query parameter, compose it as `SecretText` (see `secretstrsubstno-for-composing-secrets.md`), pass it to `SetSecretRequestUri`, and call `Get('', Response)` with an empty string as the URI argument. When the credential is an authorization header, build the header value as `SecretText` and pass it to `Headers.Add`. Use `ContainsSecret` rather than `Contains` to check for the presence of a secret header. See sample: `secrettext-with-httpclient.good.al`. + +## Anti Pattern + +Calling `ApiKey.Unwrap()` to build a URI or header string and passing the resulting `Text` to `HttpClient.Get` or `Headers.Add`. The unwrapped secret is now visible in the debugger, in any HTTP trace that captures the request URI, and in any error that includes the URI. Reviewers should flag any `Unwrap()` call whose result flows into an `HttpClient` argument; the `SecretText` overload exists precisely so the unwrap is not needed. See sample: `secrettext-with-httpclient.bad.al`. diff --git a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.bad.al b/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.bad.al deleted file mode 100644 index d22f177..0000000 --- a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.bad.al +++ /dev/null @@ -1,7 +0,0 @@ -permissionset 50203 "Sec Sample Direct Write" -{ - Assignable = true; - Caption = 'Direct write granted to every caller (sample anti-pattern)'; - Permissions = - tabledata "Sales Header" = RM; -} diff --git a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.good.al b/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.good.al deleted file mode 100644 index c2d3e20..0000000 --- a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.good.al +++ /dev/null @@ -1,34 +0,0 @@ -permissionset 50202 "Sec Sample Elevated Write" -{ - Assignable = false; - Caption = 'Elevated write via helper (sample)'; - // Callers hold R directly; the helper codeunit assumes this set and - // performs the Modify via indirect permission. - Permissions = - tabledata "Sales Header" = Rmi; -} - -codeunit 50231 "Sec Sample Elevated Helper" -{ - Access = Public; - Permissions = tabledata "Sales Header" = Rmi; - - procedure SetExternalDocumentNo(SalesDocType: Enum "Sales Document Type"; SalesDocNo: Code[20]; NewExternalDocNo: Code[35]) - var - SalesHeader: Record "Sales Header"; - begin - ValidateCaller(); - if NewExternalDocNo = '' then - Error('External document number must be provided.'); - if not SalesHeader.Get(SalesDocType, SalesDocNo) then - Error('Sales document not found.'); - SalesHeader."External Document No." := NewExternalDocNo; - SalesHeader.Modify(true); - end; - - local procedure ValidateCaller() - begin - // Verify the caller is permitted to perform this elevated write - // (role check, setup flag, approvals, etc.). - end; -} diff --git a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md b/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md deleted file mode 100644 index 58f69ac..0000000 --- a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [indirect-permission, elevation, permissionset] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use indirect permissions for elevated access - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Indirect permissions (ri, ii, mi, di) let a procedure perform an operation against tabledata the caller does not have direct rights to, provided the caller is authorized to invoke the procedure. They are the supported mechanism for elevation: instead of widening every caller's direct rights to M or D, the sensitive operation lives in a codeunit that holds the indirect right and validates its callers. - -## Best Practice - -Where a module exposes a controlled write or delete against a sensitive table, grant the codeunit (or the helper permission set it assumes) the indirect permission (mi, di) it requires, keep direct permissions minimal, and document why the elevation is justified. The helper MUST validate its inputs and the caller's identity before performing the elevated work. - -See sample: `use-indirect-permissions-for-elevated-access.good.al`. - -## Anti Pattern - -Granting direct M or D on a sensitive tabledata to every role that might invoke a helper, because authoring an indirect-permission codeunit was inconvenient. Every caller now has the elevated right for every code path, not just the one the helper implements. - -See sample: `use-indirect-permissions-for-elevated-access.bad.al`. - diff --git a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.bad.al b/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.bad.al deleted file mode 100644 index 25a6740..0000000 --- a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50205 "Sec Sample Inherent Bad" -{ - // No InherentPermissions attribute: every caller must hold - // tabledata "Sec Sample Lookup" = R just to look up a name. - procedure GetLookupName(LookupCode: Code[20]): Text[100] - var - Lookup: Record "Sec Sample Lookup"; - begin - if Lookup.Get(LookupCode) then - exit(Lookup.Name); - exit(''); - end; -} diff --git a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.good.al b/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.good.al deleted file mode 100644 index a1e5898..0000000 --- a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.good.al +++ /dev/null @@ -1,28 +0,0 @@ -table 50230 "Sec Sample Lookup" -{ - DataClassification = SystemMetadata; - - fields - { - field(1; "Code"; Code[20]) { } - field(2; "Name"; Text[100]) { } - } - - keys - { - key(PK; "Code") { Clustered = true; } - } -} - -codeunit 50204 "Sec Sample Inherent Good" -{ - [InherentPermissions(PermissionObjectType::TableData, Database::"Sec Sample Lookup", 'r')] - procedure GetLookupName(LookupCode: Code[20]): Text[100] - var - Lookup: Record "Sec Sample Lookup"; - begin - if Lookup.Get(LookupCode) then - exit(Lookup.Name); - exit(''); - end; -} diff --git a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md b/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md deleted file mode 100644 index 7056595..0000000 --- a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [inherentpermissions, attribute, least-privilege] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use InherentPermissions to grant minimal access - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -The InherentPermissions attribute attaches a minimum access grant to a procedure. Callers can invoke the procedure without holding the underlying tabledata right, because the attribute supplies exactly the right required by the procedure body and nothing more. InherentPermissions currently targets only objects owned by the same extension as the annotated procedure; it cannot be used to grant access to tables in other extensions or in the base application. - -## Best Practice - -Annotate read-only helper procedures with InherentPermissions specifying only the tables and access letters the body uses (typically 'r'). Callers do not need direct read rights on the underlying extension-owned table, so the calling role can be narrower. This is the narrowest of the elevation options and is appropriate for read-only lookup helpers. - -See sample: `use-inherent-permissions-to-grant-minimal-access.good.al`. - -## Anti Pattern - -A helper that reads a single lookup value but forces every calling role to hold tabledata read rights, because the helper does not declare its own inherent permissions. The broad read right then applies to every other code path that role can reach, not just the helper. - -See sample: `use-inherent-permissions-to-grant-minimal-access.bad.al`. - diff --git a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al deleted file mode 100644 index e0572f6..0000000 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al +++ /dev/null @@ -1,18 +0,0 @@ -codeunit 50209 "Sec Sample IsolatedStorage Bad" -{ - procedure StoreApiKey(NewKey: Text) - begin - // Plaintext write to IsolatedStorage is not encrypted at rest. - IsolatedStorage.Set('ApiKey', NewKey, DataScope::Module); - end; - - procedure GetApiKey(): Text - var - ApiKey: Text; - begin - // Public wrapper: another extension can call this to read the secret. - if IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey) then - exit(ApiKey); - exit(''); - end; -} diff --git a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al deleted file mode 100644 index 5dafba8..0000000 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50208 "Sec Sample IsolatedStorage Good" -{ - internal procedure StoreApiKey(NewKey: SecretText) - begin - IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module); - end; - - local procedure TryGetApiKey(var ApiKey: SecretText): Boolean - begin - if IsolatedStorage.Contains('ApiKey', DataScope::Module) then - exit(IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey)); - exit(false); - end; -} diff --git a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md deleted file mode 100644 index 7e9d15b..0000000 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [isolatedstorage, encryption, datascope, secrets] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use IsolatedStorage for module and company secrets - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -IsolatedStorage is a per-extension, per-tenant key-value store. DataScope::Module isolates values to the extension across the tenant; DataScope::Company scopes them to a single company within the tenant. The SetEncrypted method stores the value encrypted at rest; Set stores it in plaintext. SetEncrypted accepts inputs up to 215 characters (special characters may consume more space). - -## Best Practice - -Use IsolatedStorage.SetEncrypted to write secrets, IsolatedStorage.Contains to probe, and IsolatedStorage.Get into a SecretText destination to read. Choose DataScope::Company for per-company credentials (for example, a tenant-per-company service account) and DataScope::Module for extension-wide configuration. Procedures that call IsolatedStorage.Get, Set, SetEncrypted, Contains, or Delete must be `local` or `internal`; a public wrapper lets other extensions call into your storage boundary. - -See sample: `use-isolated-storage-for-module-and-company-secrets.good.al`. - -## Anti Pattern - -Storing secrets in a Setup table column as plain Text, using IsolatedStorage.Set (unencrypted) for values that authenticate the extension to an external service, or exposing a public Get/Set procedure around IsolatedStorage. The first two leave secrets readable; the public wrapper lets another extension exfiltrate or overwrite values through your codeunit. - -See sample: `use-isolated-storage-for-module-and-company-secrets.bad.al`. - diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al deleted file mode 100644 index 4058959..0000000 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al +++ /dev/null @@ -1,21 +0,0 @@ -codeunit 50217 "Sec Sample NonDebuggable Bad" -{ - // Missing [NonDebuggable]: ResponseText and the extracted token are - // inspectable in the debugger and in snapshot debug sessions. - procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) - var - ResponseText: Text; - JObject: JsonObject; - JToken: JsonToken; - begin - Response.Content.ReadAs(ResponseText); - JObject.ReadFrom(ResponseText); - JObject.Get('access_token', JToken); - SessionToken := JToken.AsValue().AsText(); - end; - - procedure BuildAuthorizationHeader(ApiKey: SecretText): Text - begin - exit('Bearer ' + ApiKey.Unwrap()); - end; -} diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al deleted file mode 100644 index 23b00a9..0000000 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al +++ /dev/null @@ -1,21 +0,0 @@ -codeunit 50216 "Sec Sample NonDebuggable Good" -{ - [NonDebuggable] - procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) - var - ResponseText: Text; - JObject: JsonObject; - JToken: JsonToken; - begin - Response.Content.ReadAs(ResponseText); - JObject.ReadFrom(ResponseText); - JObject.Get('access_token', JToken); - SessionToken := JToken.AsValue().AsText(); - end; - - [NonDebuggable] - procedure BuildAuthorizationHeader(ApiKey: SecretText): Text - begin - exit('Bearer ' + ApiKey.Unwrap()); - end; -} diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md deleted file mode 100644 index 19c776c..0000000 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [nondebuggable, secrettext, attribute, parse] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use NonDebuggable when parsing secrets - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -SecretText transit (assignment between SecretText variables, parameters, and return values) is protected automatically. Extracting a secret from a Text source — for example, reading an access token out of a parsed JSON response — is a legitimate Text-to-SecretText conversion during which the plaintext exists. Calling `SecretText.Unwrap()` has the same exposure in the opposite direction: it materializes the secret as plain Text. The [NonDebuggable] attribute prevents debuggers (regular and snapshot) from inspecting the procedure's locals, parameters, and return at that moment. - -## Best Practice - -Apply [NonDebuggable] to any procedure that reads a response body, parses it, and assigns the extracted secret to a SecretText out-parameter or return. Also apply it to every procedure that calls `Unwrap()` because the secret becomes plain Text inside that procedure. Keep the procedure narrow: it SHOULD do the minimum work required to obtain or unwrap the secret, and nothing else. - -See sample: `use-nondebuggable-when-parsing-secrets.good.al`. - -## Anti Pattern - -Parsing a token response in a normal (debuggable) procedure, or calling `ApiKey.Unwrap()` there to build a legacy Text value. The plaintext token is visible in debug sessions and snapshots taken during the parse or unwrap. - -See sample: `use-nondebuggable-when-parsing-secrets.bad.al`. - diff --git a/microsoft/knowledge/security/use-secrettext-for-credentials.bad.al b/microsoft/knowledge/security/use-secrettext-for-credentials.bad.al deleted file mode 100644 index cef9e4a..0000000 --- a/microsoft/knowledge/security/use-secrettext-for-credentials.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50211 "Sec Sample SecretText Bad" -{ - procedure SendAuthenticatedRequest(BearerToken: Text) - var - Client: HttpClient; - Response: HttpResponseMessage; - AuthValue: Text; - begin - AuthValue := 'Bearer ' + BearerToken; - Client.DefaultRequestHeaders.Add('Authorization', AuthValue); - Client.Get('https://api.example.com/data', Response); - end; -} diff --git a/microsoft/knowledge/security/use-secrettext-for-credentials.good.al b/microsoft/knowledge/security/use-secrettext-for-credentials.good.al deleted file mode 100644 index 269d50f..0000000 --- a/microsoft/knowledge/security/use-secrettext-for-credentials.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50210 "Sec Sample SecretText Good" -{ - procedure SendAuthenticatedRequest(BearerToken: SecretText) - var - Client: HttpClient; - Headers: HttpHeaders; - Response: HttpResponseMessage; - AuthValue: SecretText; - begin - AuthValue := SecretStrSubstNo('Bearer %1', BearerToken); - Client.DefaultRequestHeaders.Add('Authorization', AuthValue); - Client.Get('https://api.example.com/data', Response); - end; -} diff --git a/microsoft/knowledge/security/use-secrettext-for-credentials.md b/microsoft/knowledge/security/use-secrettext-for-credentials.md deleted file mode 100644 index 505d695..0000000 --- a/microsoft/knowledge/security/use-secrettext-for-credentials.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [secrettext, credentials, debugger, type] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use SecretText for credentials - -## Description - -SecretText is a compile-time-checked AL type for credentials, API keys, tokens, and similar sensitive values. The compiler rejects literal assignments to SecretText and blocks implicit conversion back to Text or Code, which prevents many accidental disclosures via logs, errors, and the debugger (regular and snapshot). A SecretText value remains opaque throughout its lifetime. - -## Best Practice - -Type every credential-carrying variable, procedure parameter, and return as SecretText. Compose values with SecretStrSubstNo (see compose-secrets-with-secretstrsubstno). For HttpClient integration, see use-secrettext-with-httpclient. When a secret must be extracted from a Text source, contain that conversion in a NonDebuggable procedure (see use-nondebuggable-when-parsing-secrets). - -See sample: `use-secrettext-for-credentials.good.al`. - -## Anti Pattern - -Passing credentials around as Text or Code parameters. Every such variable is visible in the debugger and may be captured by error handlers, logs, and telemetry that treat Text as non-sensitive. - -See sample: `use-secrettext-for-credentials.bad.al`. - diff --git a/microsoft/knowledge/security/use-secrettext-with-httpclient.bad.al b/microsoft/knowledge/security/use-secrettext-with-httpclient.bad.al deleted file mode 100644 index 4d8dd89..0000000 --- a/microsoft/knowledge/security/use-secrettext-with-httpclient.bad.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50213 "Sec Sample SecretHttpClient Bad" -{ - procedure Call(ApiKey: Text) - var - Client: HttpClient; - Response: HttpResponseMessage; - FullUrl: Text; - begin - FullUrl := 'https://api.example.com/v1?key=' + ApiKey; - Client.Get(FullUrl, Response); - end; -} diff --git a/microsoft/knowledge/security/use-secrettext-with-httpclient.good.al b/microsoft/knowledge/security/use-secrettext-with-httpclient.good.al deleted file mode 100644 index 2ea9a73..0000000 --- a/microsoft/knowledge/security/use-secrettext-with-httpclient.good.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50212 "Sec Sample SecretHttpClient Good" -{ - procedure Call(ApiKey: SecretText) - var - Client: HttpClient; - Request: HttpRequestMessage; - Response: HttpResponseMessage; - SecretUri: SecretText; - begin - SecretUri := SecretStrSubstNo('https://api.example.com/v1?key=%1', ApiKey); - Request.SetSecretRequestUri(SecretUri); - Request.Method('GET'); - Client.Send(Request, Response); - end; -} diff --git a/microsoft/knowledge/security/use-secrettext-with-httpclient.md b/microsoft/knowledge/security/use-secrettext-with-httpclient.md deleted file mode 100644 index 9b73fec..0000000 --- a/microsoft/knowledge/security/use-secrettext-with-httpclient.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [httpclient, secrettext, headers, uri] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use SecretText with HttpClient - -## Description - -HttpRequestMessage, HttpHeaders, and HttpContent expose SecretText overloads so credentials never have to be converted back to Text to be sent. Key APIs: HttpRequestMessage.SetSecretRequestUri (for URIs containing secrets), HttpHeaders.Add(name, SecretText) for authorization headers, HttpHeaders.ContainsSecret to probe secret-valued headers, HttpContent.WriteFrom(SecretText) for request bodies, and HttpContent.ReadAs(SecretText) to pull response bodies into a secret destination. - -## Best Practice - -Use HttpRequestMessage.SetSecretRequestUri when any URI component is sensitive (for example, a per-call API key in the path or query), and send the request with HttpClient.Send. Add Authorization headers as SecretText. Check for the presence of a secret header with ContainsSecret, not Contains. - -See sample: `use-secrettext-with-httpclient.good.al`. - -## Anti Pattern - -Materializing a URI or header value as Text to 'just get it to compile' — for example, StrSubstNo into a Text and then HttpClient.Get(FullUrl, Response). The resulting Text is visible in debuggers, and the URL is typically captured by platform-level logging the extension does not control. - -See sample: `use-secrettext-with-httpclient.bad.al`. - diff --git a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.bad.al b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.bad.al deleted file mode 100644 index 391f298..0000000 --- a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50241 "Sec Sample Url Bad" -{ - procedure Sync(ServiceUrl: Text) - var - Client: HttpClient; - Response: HttpResponseMessage; - begin - Client.Get(ServiceUrl, Response); - end; -} diff --git a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.good.al b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.good.al deleted file mode 100644 index 5fb72fb..0000000 --- a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.good.al +++ /dev/null @@ -1,17 +0,0 @@ -codeunit 50240 "Sec Sample Url Good" -{ - procedure Sync(ServiceUrl: Text) - var - Client: HttpClient; - Response: HttpResponseMessage; - Uri: Codeunit Uri; - ExpectedBaseUrl: Text; - begin - ExpectedBaseUrl := 'https://api.contoso.com'; - - if not Uri.AreURIsHaveSameHost(ServiceUrl, ExpectedBaseUrl) then - Error('Service URL must point to api.contoso.com.'); - - Client.Get(ServiceUrl, Response); - end; -} diff --git a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.md b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.md deleted file mode 100644 index 7a26165..0000000 --- a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [url, uri, httpclient, ssrf, validation, endpoint] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Validate user-configurable URLs before HTTP calls - -## Description - -URLs stored in setup tables or accepted from user input are user-configurable endpoints. Passing them directly to `HttpClient` lets a malicious or compromised setup value redirect the extension to internal services, metadata endpoints, or attacker-controlled hosts. Business Central's System Application `Uri` codeunit provides host and pattern validation helpers for this exact boundary. - -## Best Practice - -Before `HttpClient.Get`, `Post`, `Put`, or similar calls use a URL from a table field, validate it with `Uri.AreURIsHaveSameHost()` when the host must be fixed, or `Uri.IsValidURIPattern()` when a known URL pattern is allowed. Validate before writing the request body so sensitive payloads are never sent to an unexpected host. - -See sample: `validate-user-configurable-urls-before-http-calls.good.al`. - -## Anti Pattern - -Reading `Setup."Service URL"` or `WebhookSetup."Callback URL"` and passing it directly to HttpClient. The code looks configurable, but it creates an SSRF path and can exfiltrate data to whichever host the setup row names. - -See sample: `validate-user-configurable-urls-before-http-calls.bad.al`. diff --git a/microsoft/knowledge/security/validate-user-configurable-urls.bad.al b/microsoft/knowledge/security/validate-user-configurable-urls.bad.al new file mode 100644 index 0000000..a5b0658 --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls.bad.al @@ -0,0 +1,20 @@ +codeunit 50222 "Sec Sample UrlValidation Bad" +{ + procedure SyncWithExternalService(ServiceUrl: Text) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + begin + HttpClient.Get(ServiceUrl, Response); + end; + + procedure SendWebhookNotification(CallbackUrl: Text; Payload: Text) + var + HttpClient: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + begin + Content.WriteFrom(Payload); + HttpClient.Post(CallbackUrl, Content, Response); + end; +} diff --git a/microsoft/knowledge/security/validate-user-configurable-urls.good.al b/microsoft/knowledge/security/validate-user-configurable-urls.good.al new file mode 100644 index 0000000..0925b14 --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls.good.al @@ -0,0 +1,24 @@ +codeunit 50221 "Sec Sample UrlValidation Good" +{ + procedure SyncWithExternalService(ServiceUrl: Text) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Uri: Codeunit Uri; + begin + if not Uri.AreURIsHaveSameHost(ServiceUrl, 'https://api.contoso.com') then + Error('Service URL must point to api.contoso.com'); + HttpClient.Get(ServiceUrl, Response); + end; + + procedure SyncWithShopify(ShopUrl: Text) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Uri: Codeunit Uri; + begin + if not Uri.IsValidURIPattern(ShopUrl, 'https://*.myshopify.com/*') then + Error('Shop URL must match the Shopify pattern'); + HttpClient.Get(ShopUrl + '/admin/api/2024-01/orders.json', Response); + end; +} diff --git a/microsoft/knowledge/security/validate-user-configurable-urls.md b/microsoft/knowledge/security/validate-user-configurable-urls.md new file mode 100644 index 0000000..06cc31d --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [ssrf, uri, url-validation, areurishavesamehost, isvaliduripattern, httpclient] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Validate URLs that come from table fields before calling them + +## Description + +A URL stored in a table field is user-configurable: anyone with write access to the row can change it. If that URL is then used as the target of an `HttpClient.Get`/`Post`, the extension becomes a server-side request forgery (SSRF) primitive — an attacker can redirect the call to an internal endpoint, to a metadata service, or to a malicious host that mirrors the legitimate API. The `Uri` codeunit from System Modules provides two validators built for this situation: `AreURIsHaveSameHost()` checks that two URLs share the same host (use when the hostname should not change — for example, the extension always talks to `api.contoso.com`). `IsValidURIPattern()` checks that a URL matches a wildcard pattern (use when the host varies but follows a predictable shape — for example `https://{store}.myshopify.com/...`). + +## Best Practice + +Before any `HttpClient` call whose URL came from a table field, call `Uri.AreURIsHaveSameHost(StoredUrl, ExpectedBaseUrl)` against a hard-coded expected base, or `Uri.IsValidURIPattern(StoredUrl, 'https://*.myshopify.com/*')` against a fixed pattern. Fail the call with an `Error` when the validator returns false. For webhook scenarios where the host is registered out-of-band, compare against the registered host stored alongside the URL. See sample: `validate-user-configurable-urls.good.al`. + +## Anti Pattern + +`HttpClient.Get(Setup."Service URL", Response)` or `HttpClient.Post(WebhookSetup."Callback URL", Content, Response)` with no validation step in between. The extension will dutifully send the request — and any sensitive payload — to whatever host the attacker put in the field. Reviewers should flag any `HttpClient` call whose first argument is a record field, an `OnValidate`-mutable field, or a value sourced from a table read, unless a `Uri.AreURIsHaveSameHost` or `Uri.IsValidURIPattern` check precedes it. See sample: `validate-user-configurable-urls.bad.al`. diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.bad.al b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.bad.al new file mode 100644 index 0000000..7029908 --- /dev/null +++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.bad.al @@ -0,0 +1,11 @@ +tableextension 50225 "Sec Sample VTR Bad" extends Customer +{ + fields + { + field(50225; "Linked Customer No."; Code[20]) + { + TableRelation = Customer."No."; + ValidateTableRelation = false; + } + } +} diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al new file mode 100644 index 0000000..9a49bc3 --- /dev/null +++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al @@ -0,0 +1,26 @@ +tableextension 50223 "Sec Sample VTR Good" extends Customer +{ + fields + { + field(50223; "System Batch ID"; Code[20]) + { + TableRelation = "Sales Header"."No."; + ValidateTableRelation = false; + Editable = false; + } + field(50224; "External Customer Ref"; Code[50]) + { + TableRelation = Customer."No."; + ValidateTableRelation = false; + trigger OnValidate() + var + Customer: Record Customer; + begin + if "External Customer Ref" = '' then + exit; + if not Customer.Get("External Customer Ref") then + Error('External customer reference %1 does not exist.', "External Customer Ref"); + end; + } + } +} diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md new file mode 100644 index 0000000..275587c --- /dev/null +++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [validatetablerelation, tablerelation, field, validation, input] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not set ValidateTableRelation = false on user-editable fields + +## Description + +`TableRelation` on a field declares that the field's value must exist in another table; the platform validates the value on entry and on `Validate`. Setting `ValidateTableRelation = false` keeps the relation as metadata (used by lookups, by Edit-in-Excel, by APIs) but turns off the runtime check. On a system-controlled, non-editable field that is populated only by the platform or by a posting routine, that is acceptable. On a user-editable field, it is dangerous: users can type any value, and downstream code that assumes the relation holds will read a `Customer` record that does not exist, post to an account that was deleted, or join against missing rows. + +## Best Practice + +Leave `ValidateTableRelation` at its default (true) on any field a user can edit. If there is a legitimate reason to turn it off — typically because the relation is not on the primary key, or because the relation is computed — replace it with an `OnValidate` trigger that performs the equivalent check (`if FieldValue <> '' then VerifyExternalReferenceExists(FieldValue)`). Combine `ValidateTableRelation = false` with `Editable = false` for system-controlled fields, so the metadata is correct and the field is unreachable from the UI. See sample: `validatetablerelation-false-on-user-input.good.al`. + +## Anti Pattern + +`ValidateTableRelation = false` on a user-facing input field (a `Customer No.` typed by a sales user) with no alternative validation. Reviewers should flag the combination of `ValidateTableRelation = false` and any of: `Editable = true` (the default), an `OnValidate` trigger that does not perform the relation check, or a page that surfaces the field as input. See sample: `validatetablerelation-false-on-user-input.bad.al`. diff --git a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.bad.al b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.bad.al new file mode 100644 index 0000000..a32072f --- /dev/null +++ b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.bad.al @@ -0,0 +1,5 @@ +page 50258 "Sample AboutTitle Bad" +{ + PageType = List; + SourceTable = Customer; +} diff --git a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.good.al b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.good.al new file mode 100644 index 0000000..b497974 --- /dev/null +++ b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.good.al @@ -0,0 +1,15 @@ +page 50256 "Sample AboutTitle Good List" +{ + PageType = List; + SourceTable = Customer; + AboutTitle = 'About customers'; + AboutText = 'Manage your customer database and track customer interactions. You can create new customers, update contact information, and view customer statistics.'; +} + +page 50257 "Sample AboutTitle Good Card" +{ + PageType = Card; + SourceTable = Customer; + AboutTitle = 'About customer details'; + AboutText = 'View and edit detailed customer information including contact details, payment terms, and billing preferences.'; +} diff --git a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md new file mode 100644 index 0000000..f72b959 --- /dev/null +++ b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [abouttitle, abouttext, teaching-tip, onboarding, page] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use `AboutTitle` and `AboutText` to surface teaching tips on top-level pages + +## Description + +The `AboutTitle` and `AboutText` properties on a page render a teaching tip — an onboarding callout that appears the first time a user opens the page. They are supported on pages, individual page controls, FactBoxes, and report request pages. They are NOT supported on Role Centers or modal dialogs. The conventions: `AboutTitle` answers "what is this page about?" and uses the plural for list pages (`'About sales invoices'`) and the `[entity] details` form for card and document pages (`'About sales invoice details'`); `AboutText` answers "what can I do with this page?" in two or three short sentences. Both are translation-aware and surface to the end user verbatim. + +The reviewer signal is "this is a new top-level card or list page in an app whose sibling pages already define teaching tips" — when the surrounding app sets the precedent, a new page without `AboutTitle`/`AboutText` is an inconsistency worth flagging. + +## Best Practice + +Set `AboutTitle` and `AboutText` on every new top-level card, list, and document page in an app that already uses them. Keep `AboutText` to two or three short sentences. Describe what the page does, not the navigation steps to use it — teaching tips explain WHAT, not HOW. + +See sample: `abouttitle-abouttext-teaching-tips.good.al`. + +## Anti Pattern + +A new top-level page in an app whose siblings have `AboutTitle`/`AboutText`, but with no teaching tips defined. Equally wrong is filling `AboutText` with step-by-step instructions ("Click New, then enter…") — the property is for orientation, not procedural help. + +See sample: `abouttitle-abouttext-teaching-tips.bad.al`. diff --git a/microsoft/knowledge/style/api-page-camelcase-properties.bad.al b/microsoft/knowledge/style/api-page-camelcase-properties.bad.al new file mode 100644 index 0000000..47f2f11 --- /dev/null +++ b/microsoft/knowledge/style/api-page-camelcase-properties.bad.al @@ -0,0 +1,10 @@ +page 50219 "Sample API Camel Bad" +{ + PageType = API; + APIPublisher = 'Contoso-App'; + APIGroup = 'app_1'; + APIVersion = 'v2.0'; + EntityName = 'sales_order'; + EntitySetName = 'sales_orders'; + SourceTable = "Sales Header"; +} diff --git a/microsoft/knowledge/style/follow-api-page-naming-rules.good.al b/microsoft/knowledge/style/api-page-camelcase-properties.good.al similarity index 61% rename from microsoft/knowledge/style/follow-api-page-naming-rules.good.al rename to microsoft/knowledge/style/api-page-camelcase-properties.good.al index 8e1bec5..f460bb9 100644 --- a/microsoft/knowledge/style/follow-api-page-naming-rules.good.al +++ b/microsoft/knowledge/style/api-page-camelcase-properties.good.al @@ -1,4 +1,4 @@ -page 51102 "Style Sample ApiPage Good" +page 50218 "Sample API Camel Good" { PageType = API; APIPublisher = 'contoso'; @@ -8,7 +8,6 @@ page 51102 "Style Sample ApiPage Good" EntitySetName = 'customers'; SourceTable = Customer; DelayedInsert = true; - ODataKeyFields = SystemId; layout { @@ -16,9 +15,7 @@ page 51102 "Style Sample ApiPage Good" { repeater(Group) { - field(systemId; Rec.SystemId) { } - field(number; Rec."No.") { } - field(displayName; Rec.Name) { } + field(displayName; Rec.Name) { Caption = 'displayName'; } } } } diff --git a/microsoft/knowledge/style/api-page-camelcase-properties.md b/microsoft/knowledge/style/api-page-camelcase-properties.md new file mode 100644 index 0000000..3baa009 --- /dev/null +++ b/microsoft/knowledge/style/api-page-camelcase-properties.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, camelcase, apipublisher, apigroup, entityname, entitysetname] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# API pages use camelCase, alphanumeric-only values for API properties + +## Description + +API pages — pages declared with `PageType = API` — surface as OData/JSON endpoints. The strings that appear in the URL (`APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`) and the JSON payload field names follow different naming rules from the rest of AL. They must be camelCase and use only alphanumeric characters: no hyphens, no underscores, no spaces, no punctuation. `'Contoso-App'`, `'contoso_app'`, and `'contoso.app'` are all rejected. The same rule applies to page field names exposed via `Name = '…'` on API page controls — those names appear verbatim in the JSON keys. + +## Best Practice + +Pick camelCase identifiers up front: `APIPublisher = 'contoso'`, `APIGroup = 'app1'`, `EntityName = 'customer'`, field `Name = 'displayName'`. Keep them short — they end up in URL paths and JSON keys that every consumer types. + +See sample: `api-page-camelcase-properties.good.al`. + +## Anti Pattern + +`APIPublisher = 'Contoso-App'` (hyphen rejected, capitalization wrong for camelCase), `EntityName = 'sales_order'` (underscore rejected), or fields exposed with `Name = 'Display Name'` (space rejected). The compiler usually catches these, but the failure mode is opaque and the rename cost on a deployed API is high. + +See sample: `api-page-camelcase-properties.bad.al`. diff --git a/microsoft/knowledge/style/api-page-delayedinsert-true.bad.al b/microsoft/knowledge/style/api-page-delayedinsert-true.bad.al new file mode 100644 index 0000000..4cf3fe2 --- /dev/null +++ b/microsoft/knowledge/style/api-page-delayedinsert-true.bad.al @@ -0,0 +1,10 @@ +page 50227 "Sample DelayedInsert Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; +} diff --git a/microsoft/knowledge/style/api-page-delayedinsert-true.good.al b/microsoft/knowledge/style/api-page-delayedinsert-true.good.al new file mode 100644 index 0000000..532d3cd --- /dev/null +++ b/microsoft/knowledge/style/api-page-delayedinsert-true.good.al @@ -0,0 +1,11 @@ +page 50226 "Sample DelayedInsert Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; +} diff --git a/microsoft/knowledge/style/api-page-delayedinsert-true.md b/microsoft/knowledge/style/api-page-delayedinsert-true.md new file mode 100644 index 0000000..6045c2f --- /dev/null +++ b/microsoft/knowledge/style/api-page-delayedinsert-true.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, delayedinsert, insert-trigger, validation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Set `DelayedInsert = true` on API pages + +## Description + +On a normal page, `DelayedInsert = false` is the default: the record is inserted into the table as soon as the user enters the first field, and subsequent fields are written via `Modify` triggers. That model does not work for an API endpoint, where the consumer sends a complete JSON payload in a single request and expects exactly one `Insert` to fire with all fields already populated. `DelayedInsert = true` defers the insert until every field on the page has been assigned, so the `OnInsert` trigger runs once with the full record and `OnValidate` triggers on individual fields run in a predictable order. The convention is that API pages always set `DelayedInsert = true`. + +## Best Practice + +Declare `DelayedInsert = true` on every page with `PageType = API`. The setting plays well with `Modify(true)` and `Insert(true)` calls inside `OnInsert` and avoids the half-populated record states that otherwise reach validation logic. + +See sample: `api-page-delayedinsert-true.good.al`. + +## Anti Pattern + +Omitting `DelayedInsert` (which defaults to `false`) on an API page. Validation triggers fire on a partially populated record, mandatory-field errors come back to the caller for fields the JSON payload was about to supply, and the API surface produces failures that have no analogue in the UI page model. + +See sample: `api-page-delayedinsert-true.bad.al`. diff --git a/microsoft/knowledge/style/api-page-entity-naming-singular-plural.bad.al b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.bad.al new file mode 100644 index 0000000..ecd1ec3 --- /dev/null +++ b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.bad.al @@ -0,0 +1,10 @@ +page 50225 "Sample API Entity Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customers'; + EntitySetName = 'customer'; + SourceTable = Customer; +} diff --git a/microsoft/knowledge/style/api-page-entity-naming-singular-plural.good.al b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.good.al new file mode 100644 index 0000000..72981b7 --- /dev/null +++ b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.good.al @@ -0,0 +1,23 @@ +page 50223 "Sample API Entity Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; +} + +page 50224 "Sample API Compound Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'salesOrder'; + EntitySetName = 'salesOrders'; + SourceTable = "Sales Header"; + DelayedInsert = true; +} diff --git a/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md new file mode 100644 index 0000000..6ba698e --- /dev/null +++ b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, entityname, entitysetname, singular, plural] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `EntityName` is singular; `EntitySetName` is plural + +## Description + +`EntityName` and `EntitySetName` on an API page are the two halves of the OData naming contract. `EntityName` names a single record — `'customer'`, `'salesOrder'`, `'item'`. `EntitySetName` names the collection — `'customers'`, `'salesOrders'`, `'items'`. Swapping them — `EntityName = 'customers'`, `EntitySetName = 'customer'` — produces URLs that lie to consumers: `GET /customers` returns one row, `GET /customers('id')` returns a collection. The OData conventions consumers rely on for client-side code generation depend on the singular/plural pairing being correct. + +## Best Practice + +Pick the singular noun for `EntityName` and its grammatical plural for `EntitySetName`, both in camelCase. For compound nouns, only the trailing noun is pluralized: `EntityName = 'salesOrder'`, `EntitySetName = 'salesOrders'`. For nouns whose plural is irregular, use the natural English form — `EntitySetName = 'people'` for `EntityName = 'person'`. + +See sample: `api-page-entity-naming-singular-plural.good.al`. + +## Anti Pattern + +`EntityName = 'customers'`, `EntitySetName = 'customer'` — singular and plural swapped. Equally wrong is reusing the same form for both — `EntityName = 'customer'`, `EntitySetName = 'customer'` — which breaks OData metadata parsers and client codegen. + +See sample: `api-page-entity-naming-singular-plural.bad.al`. diff --git a/microsoft/knowledge/style/api-page-version-format.bad.al b/microsoft/knowledge/style/api-page-version-format.bad.al new file mode 100644 index 0000000..2efe623 --- /dev/null +++ b/microsoft/knowledge/style/api-page-version-format.bad.al @@ -0,0 +1,10 @@ +page 50222 "Sample API Version Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v2'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; +} diff --git a/microsoft/knowledge/style/api-page-version-format.good.al b/microsoft/knowledge/style/api-page-version-format.good.al new file mode 100644 index 0000000..dc115b4 --- /dev/null +++ b/microsoft/knowledge/style/api-page-version-format.good.al @@ -0,0 +1,23 @@ +page 50220 "Sample API Version Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; +} + +page 50221 "Sample API Beta Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'beta'; + EntityName = 'preview'; + EntitySetName = 'previews'; + SourceTable = Customer; + DelayedInsert = true; +} diff --git a/microsoft/knowledge/style/api-page-version-format.md b/microsoft/knowledge/style/api-page-version-format.md new file mode 100644 index 0000000..633c53b --- /dev/null +++ b/microsoft/knowledge/style/api-page-version-format.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, apiversion, version, format, beta] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `APIVersion` must follow the pattern `vX.Y` (or `beta`) + +## Description + +The `APIVersion` property on an API page is part of the public URL path: `/api////`. The platform accepts only two value shapes for it: a `vMAJOR.MINOR` string such as `'v1.0'`, `'v2.0'`, or `'v2.1'`, or the literal string `'beta'` for pre-release endpoints. Anything else — `'v2'`, `'2.0'`, `'1'`, `'v2.0.0'` — is rejected. The major-minor pair lets consumers detect compatibility through URL inspection alone; the explicit `'beta'` channel signals "this contract may break without notice." + +## Best Practice + +Start a new public endpoint at `'v1.0'`. Bump the minor when adding fields or non-breaking changes; bump the major when changing field types, removing fields, or any breaking change. Use `'beta'` for endpoints that are still iterating and SHOULD NOT be consumed by external integrations. + +See sample: `api-page-version-format.good.al`. + +## Anti Pattern + +`APIVersion = 'v2'` (missing minor), `APIVersion = '2.0'` (missing `v` prefix), `APIVersion = 'v2.0.0'` (extra segment). All three either fail to compile or produce a URL that consumers cannot reach. + +See sample: `api-page-version-format.bad.al`. diff --git a/microsoft/knowledge/style/apply-approved-label-suffixes.bad.al b/microsoft/knowledge/style/apply-approved-label-suffixes.bad.al deleted file mode 100644 index 2893eb0..0000000 --- a/microsoft/knowledge/style/apply-approved-label-suffixes.bad.al +++ /dev/null @@ -1,17 +0,0 @@ -codeunit 51101 "Style Sample LabelSuffix Bad" -{ - procedure Example() - var - CannotDeleteLine: Label 'Cannot delete this line.'; - Text000: Label 'Update complete'; - UpdateLocation: Label 'Update location?'; - WrongSuffixTok: Label 'Customer %1 not found.', Comment = '%1 = Customer No.'; - CustomerNo: Code[20]; - begin - Error(CannotDeleteLine); - Message(Text000); - if Confirm(UpdateLocation) then - ; - Error(WrongSuffixTok, CustomerNo); - end; -} diff --git a/microsoft/knowledge/style/apply-approved-label-suffixes.good.al b/microsoft/knowledge/style/apply-approved-label-suffixes.good.al deleted file mode 100644 index 6557feb..0000000 --- a/microsoft/knowledge/style/apply-approved-label-suffixes.good.al +++ /dev/null @@ -1,20 +0,0 @@ -codeunit 51100 "Style Sample LabelSuffix Good" -{ - procedure Example() - var - UpdateCompleteMsg: Label 'Update complete.'; - CannotDeleteLineErr: Label 'Cannot delete this line.'; - UpdateLocationQst: Label 'Update location?'; - CustomerNameLbl: Label 'Customer Name'; - HttpsMethodTok: Label 'GET', Locked = true; - TelemetryCustomerUpdatedTxt: Label 'Customer updated.'; - begin - Message(UpdateCompleteMsg); - if Confirm(UpdateLocationQst) then - ; - Session.LogMessage('0001', TelemetryCustomerUpdatedTxt, - Verbosity::Normal, DataClassification::SystemMetadata, - TelemetryScope::ExtensionPublisher); - Error(CannotDeleteLineErr); - end; -} diff --git a/microsoft/knowledge/style/apply-approved-label-suffixes.md b/microsoft/knowledge/style/apply-approved-label-suffixes.md deleted file mode 100644 index 59a8f72..0000000 --- a/microsoft/knowledge/style/apply-approved-label-suffixes.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [label, textconst, suffix, msg, err, qst, tok, lbl, txt, aa0074] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Suffix every Label and TextConst with its approved usage tag - -## Description - -CodeCop rule AA0074 requires every Label and TextConst to carry a suffix indicating how the value is consumed: `Msg` for Message calls, `Err` for Error calls, `Qst` for Confirm or StrMenu prompts, `Tok` for locked tokens (URLs, JSON keys, short literals with `Locked = true`), `Lbl` for captions and tooltips, and `Txt` for telemetry strings. The suffix is not decoration — it is how the compiler, linter, and reviewer detect misuse (a `Tok` value passed to `Error`, a `Msg` used as an error label). The cost of adopting the convention is one short suffix per declaration; the cost of ignoring it is that every reviewer has to inspect every call site to judge appropriateness. - -## Best Practice - -Name every Label and TextConst with one of `Msg`, `Err`, `Qst`, `Tok`, `Lbl`, or `Txt` at the end. Pick the suffix that matches the consuming call, not the look of the string. When multiple suffixes are grammatically valid (`Tok` vs `Lbl` for a short caption on a locked token) the choice is a judgment call; the violation is missing a suffix or using one inconsistent with the call site. - -See sample: `apply-approved-label-suffixes.good.al`. - -## Anti Pattern - -`CannotDeleteLine: Label 'Cannot delete this line.';` — no suffix, used with Error. `Text000: Label 'Update complete';` — generic name with no suffix at all. `WrongSuffixTok: Label 'Customer %1 not found.'` used with Error — a Tok suffix on an error label. - -See sample: `apply-approved-label-suffixes.bad.al`. diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al new file mode 100644 index 0000000..fd3ac45 --- /dev/null +++ b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al @@ -0,0 +1,14 @@ +codeunit 50235 "Sample Begin Own Line Bad" +{ + procedure Run(Condition: Boolean) + begin + if Condition then + begin + DoSomething(); + DoSomethingElse(); + end; + end; + + local procedure DoSomething() begin end; + local procedure DoSomethingElse() begin end; +} diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al new file mode 100644 index 0000000..6043c5b --- /dev/null +++ b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al @@ -0,0 +1,24 @@ +codeunit 50234 "Sample Begin Same Line Good" +{ + procedure Run(Condition: Boolean) + var + i: Integer; + begin + if Condition then begin + DoSomething(); + DoSomethingElse(); + end else begin + Reset(); + Notify(); + end; + for i := 1 to 10 do begin + DoSomething(); + DoSomethingElse(); + end; + end; + + local procedure DoSomething() begin end; + local procedure DoSomethingElse() begin end; + local procedure Reset() begin end; + local procedure Notify() begin end; +} diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md new file mode 100644 index 0000000..fbf7aec --- /dev/null +++ b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [begin, end, compound-statement, aa0005, codecop, formatting] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `begin` goes on the same line as `then`, `else`, or `do` (CodeCop AA0005) + +## Description + +When a compound block follows `then`, `else`, or `do`, the `begin` keyword must sit on the same line as the preceding keyword, separated by exactly one space. `if Condition then begin` and `for i := 1 to N do begin` are correct. The form that puts `begin` on its own line — common in older AL and in languages like Pascal — is flagged by CodeCop AA0005. The rule does not change indentation of the block body; it only governs the placement of `begin` relative to `then`/`else`/`do`. + +## Best Practice + +`if Condition then begin … end;`, `else begin … end;`, `for i := 1 to N do begin … end;`. The block body is indented one level below the `if`/`for` line, and `end;` sits at the same indentation as the line that opened the block. + +See sample: `begin-on-same-line-as-then-else-do.good.al`. + +## Anti Pattern + +A line that ends with `then` (or `else`, or `do`) and is followed by a line whose only content is `begin`. The compiler accepts it but CodeCop AA0005 flags it; the visual cost is a wasted line per block and a layout that looks alien to readers used to current AL style. + +See sample: `begin-on-same-line-as-then-else-do.bad.al`. diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.bad.al b/microsoft/knowledge/style/block-keywords-start-new-line.bad.al new file mode 100644 index 0000000..ef7f937 --- /dev/null +++ b/microsoft/knowledge/style/block-keywords-start-new-line.bad.al @@ -0,0 +1,15 @@ +codeunit 50239 "Sample Block Kw Bad" +{ + procedure Dispatch(IsContactName: Boolean; IsSalespersonCode: Boolean) + var + i: Integer; + begin + if IsContactName then ValidateContactName() else if IsSalespersonCode then ValidateSalespersonCode(); + for i := 1 to 10 do begin DoSomething(i); DoSomethingElse(i); end; + end; + + local procedure ValidateContactName() begin end; + local procedure ValidateSalespersonCode() begin end; + local procedure DoSomething(I: Integer) begin end; + local procedure DoSomethingElse(I: Integer) begin end; +} diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.good.al b/microsoft/knowledge/style/block-keywords-start-new-line.good.al new file mode 100644 index 0000000..eb6c3d4 --- /dev/null +++ b/microsoft/knowledge/style/block-keywords-start-new-line.good.al @@ -0,0 +1,23 @@ +codeunit 50238 "Sample Block Kw Good" +{ + procedure Dispatch(IsContactName: Boolean; IsSalespersonCode: Boolean) + var + i: Integer; + begin + if IsContactName then + ValidateContactName() + else + if IsSalespersonCode then + ValidateSalespersonCode(); + + for i := 1 to 10 do begin + DoSomething(i); + DoSomethingElse(i); + end; + end; + + local procedure ValidateContactName() begin end; + local procedure ValidateSalespersonCode() begin end; + local procedure DoSomething(I: Integer) begin end; + local procedure DoSomethingElse(I: Integer) begin end; +} diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.md b/microsoft/knowledge/style/block-keywords-start-new-line.md new file mode 100644 index 0000000..d40ea61 --- /dev/null +++ b/microsoft/knowledge/style/block-keywords-start-new-line.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [block-keyword, end, if, repeat, until, for, while, case, aa0018] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Block keywords (`end`, `if`, `repeat`, `until`, `for`, `while`, `case`) start a new line (CodeCop AA0018) + +## Description + +CodeCop AA0018 requires that the block-introducing keywords `if`, `repeat`, `until`, `for`, `while`, `case`, and the block-terminating keyword `end` always start a new line. Multiple statements packed onto one line — `if A then X() else if B then Y();` written inline, or `for i := 1 to 10 do begin X(i); Y(i); end;` — defeat code review tooling that operates line-by-line and obscure the control flow. The rule does not prohibit short single-statement constructs spread across two lines (`if Cond then X();`); it prohibits packing the entire control structure onto one line. + +## Best Practice + +Each `if`, `else if`, `repeat`, `for`, `while`, and `case` starts a line. Each `end;` (the closing of a `begin … end` block or a `case`) starts a line. Branch bodies are on their own line, indented. + +See sample: `block-keywords-start-new-line.good.al`. + +## Anti Pattern + +`if IsContactName then ValidateContactName() else if IsSalespersonCode then ValidateSalespersonCode();` collapses an `if/else if` chain onto a single line; AA0018 flags both the `else` and the second `if`. The same applies to `for i := 1 to 10 do begin DoX(i); DoY(i); end;` — `end` is not at the start of its line. + +See sample: `block-keywords-start-new-line.bad.al`. diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.bad.al b/microsoft/knowledge/style/caption-required-on-page-fields.bad.al new file mode 100644 index 0000000..bd12f36 --- /dev/null +++ b/microsoft/knowledge/style/caption-required-on-page-fields.bad.al @@ -0,0 +1,13 @@ +table 50253 "Sample Caption Bad" +{ + fields + { + field(1; "Customer No."; Code[20]) + { + } + field(2; "Is Active"; Boolean) + { + Caption = ''; + } + } +} diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.good.al b/microsoft/knowledge/style/caption-required-on-page-fields.good.al new file mode 100644 index 0000000..7de715b --- /dev/null +++ b/microsoft/knowledge/style/caption-required-on-page-fields.good.al @@ -0,0 +1,17 @@ +table 50252 "Sample Caption Good" +{ + fields + { + field(1; "Customer No."; Code[20]) + { + Caption = 'Customer No.'; + } + field(2; "Enabled"; Boolean) + { + } + field(3; Amount; Decimal) + { + CaptionClass = '3,5,' + 'USD'; + } + } +} diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.md b/microsoft/knowledge/style/caption-required-on-page-fields.md new file mode 100644 index 0000000..e3a69c3 --- /dev/null +++ b/microsoft/knowledge/style/caption-required-on-page-fields.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [caption, page-field, aa0225, aa0226, codecop, captionclass] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every page field needs a `Caption` (CodeCop AA0225/AA0226) + +## Description + +CodeCop AA0225 and AA0226 require every field control to expose a `Caption` property, separately from the field's source name. The caption is what the user sees as the column header or label; the source name is what the code uses to reference the field. Without an explicit `Caption`, AL falls back to the source field's caption — which may be wrong for the page's context — or to the field name itself in code casing, which surfaces internal naming to users and to translators. + +Acceptable exceptions: a field whose caption is inherited via `CaptionClass = '3,5,' + CurrencyCode` (or another CaptionClass formula) does not need a literal `Caption`; the formula provides it. API pages and test pages may omit captions because their consumers are not human users. Boolean fields whose name already reads as a sentence — `Enabled`, `Posted`, `Released` — do not need a redundant Caption that repeats the name. + +## Best Practice + +`Caption = 'Customer No.';` paired with `ToolTip = 'Specifies …';`. Captions are short, noun-phrase, title-case for primary labels; sentence-case is allowed for descriptive labels that read as a sentence fragment. + +See sample: `caption-required-on-page-fields.good.al`. + +## Anti Pattern + +A field control with no `Caption` and no `CaptionClass`, or `Caption = '';`. The user sees the internal identifier as the column header and the translation pipeline has nothing to translate. + +See sample: `caption-required-on-page-fields.bad.al`. diff --git a/microsoft/knowledge/style/case-action-on-line-after-possibility.bad.al b/microsoft/knowledge/style/case-action-on-line-after-possibility.bad.al new file mode 100644 index 0000000..e4fbe58 --- /dev/null +++ b/microsoft/knowledge/style/case-action-on-line-after-possibility.bad.al @@ -0,0 +1,16 @@ +codeunit 50241 "Sample Case Format Bad" +{ + procedure Translate(Letter: Char): Code[10] + var + Letter2: Code[10]; + begin + case Letter of + 'A': Letter2 := '10'; + 'B': Letter2 := '11'; + 'C': begin Letter2 := '12'; DoSomething(); end; + end; + exit(Letter2); + end; + + local procedure DoSomething() begin end; +} diff --git a/microsoft/knowledge/style/case-action-on-line-after-possibility.good.al b/microsoft/knowledge/style/case-action-on-line-after-possibility.good.al new file mode 100644 index 0000000..a7ff731 --- /dev/null +++ b/microsoft/knowledge/style/case-action-on-line-after-possibility.good.al @@ -0,0 +1,21 @@ +codeunit 50240 "Sample Case Format Good" +{ + procedure Translate(Letter: Char): Code[10] + var + Letter2: Code[10]; + begin + case Letter of + 'A': + Letter2 := '10'; + 'B': + Letter2 := '11'; + 'C': begin + Letter2 := '12'; + DoSomething(); + end; + end; + exit(Letter2); + end; + + local procedure DoSomething() begin end; +} diff --git a/microsoft/knowledge/style/case-action-on-line-after-possibility.md b/microsoft/knowledge/style/case-action-on-line-after-possibility.md new file mode 100644 index 0000000..c928375 --- /dev/null +++ b/microsoft/knowledge/style/case-action-on-line-after-possibility.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [case, statement, formatting, possibility, action, line-break] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `case` action goes on the line after the possibility + +## Description + +In an AL `case` statement, the action for each label is written on the line that follows the label, not on the same line. `'A': Letter2 := '10';` on a single line is the discouraged form; the convention is `'A':` on one line and `Letter2 := '10';` on the next, indented one level deeper. The exception is when the action is a `begin … end` block — there the `begin` follows the colon on the same line, consistent with the rule for `then begin` / `else begin` / `do begin`. + +## Best Practice + +Each case label sits on its own line, terminated by `:`. The action below it is indented; multi-statement actions open with `begin` on the label line and close with `end;` on its own line. + +See sample: `case-action-on-line-after-possibility.good.al`. + +## Anti Pattern + +`'A': Letter2 := '10';` (single-line label and action), and `'C': begin Letter2 := '12'; DoSomething(); end;` (everything on one line including the block body). Both defeat per-line diff review and crowd the control flow. + +See sample: `case-action-on-line-after-possibility.bad.al`. diff --git a/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.bad.al b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.bad.al new file mode 100644 index 0000000..4d47c31 --- /dev/null +++ b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.bad.al @@ -0,0 +1,15 @@ +codeunit 50207 "Sample Error Params Bad" +{ + var + CustomerNotFoundErr: Label 'Customer %1 does not exist.'; + + procedure CheckCustomer(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if not Customer.Get(CustomerNo) then + Error(StrSubstNo(CustomerNotFoundErr, CustomerNo)); + if not Customer.Get(CustomerNo) then + Error('Customer ' + CustomerNo + ' not found'); + end; +} diff --git a/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.good.al b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.good.al new file mode 100644 index 0000000..96e3d9c --- /dev/null +++ b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.good.al @@ -0,0 +1,13 @@ +codeunit 50206 "Sample Error Params Good" +{ + var + CustomerNotFoundErr: Label 'Customer %1 does not exist.'; + + procedure CheckCustomer(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if not Customer.Get(CustomerNo) then + Error(CustomerNotFoundErr, CustomerNo); + end; +} diff --git a/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md new file mode 100644 index 0000000..f8ee5bb --- /dev/null +++ b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [error, strsubstno, label, parameters, concatenation, aa0231] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pass parameters directly to `Error()`, do not wrap with `StrSubstNo` + +## Description + +`Error()` accepts a format string and a variable number of arguments — `Error(SomeLabelErr, Arg1, Arg2)`. The platform performs the substitution itself, which is the path the translation pipeline understands. Wrapping the same call as `Error(StrSubstNo(SomeLabelErr, Arg1, Arg2))` hides the placeholders from the platform and removes the format-string identity from the call-site, so analyzers cannot match the call to its label and translators lose the link between the formatted message and its template. The corresponding anti-pattern for hardcoded strings — `Error('Customer ' + CustomerNo + ' not found')` — is even worse: it builds an untranslatable, unanalyzable string at runtime. + +## Best Practice + +Declare a `Label` with the `Err` suffix and the appropriate `Comment` for placeholders, then call `Error(YourErr, arg1, arg2)`. The same rule applies to `Message`, `Confirm`, and other UI primitives: format string in, parameters as separate arguments, no `StrSubstNo` wrapper at the call site, no string concatenation. An `Error('')` (empty message) is acceptable when the calling code expects another layer to emit the actual diagnostic. + +See sample: `error-passes-parameters-directly-not-strsubstno.good.al`. + +## Anti Pattern + +`Error(StrSubstNo(CustomerNotFoundErr, CustomerNo))` and `Error(CustomerNotFoundErr + ': ' + CustomerNo)` both defeat the translation and analysis machinery. Reviewers should treat `StrSubstNo` appearing as an argument to `Error`, `Message`, `Confirm`, or `StrMenu` as an unconditional signal to rewrite. + +See sample: `error-passes-parameters-directly-not-strsubstno.bad.al`. diff --git a/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md b/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md new file mode 100644 index 0000000..aaf3729 --- /dev/null +++ b/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: style +keywords: [event-subscriber, parameter-name, publisher, signature, eventsubscriber] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Event subscriber parameter names must match the publisher signature + +## Description + +In AL, an `[EventSubscriber]` procedure is bound to its publisher by event name and parameter list. The parameter names on the subscriber are not a style choice — they must match the names the publisher declared. The compiler validates the match at build time and emits an error if the subscriber renames a parameter. This means a reviewer cannot apply a generic "use better names" pass to subscriber parameters: `Sender`, `Rec`, `xRec`, `RunTrigger`, the table-and-field-specific parameter names a publisher emits — all are dictated by the publisher and must be reproduced verbatim. + +## Best Practice + +Copy the publisher signature exactly when declaring the subscriber. When in doubt, navigate to the publisher (`OnAfterValidateEvent`, `OnBeforePostSalesDoc`, etc.) and copy its parameter list. Style rules that apply to other locals — descriptive names, no spaces — do not apply to subscriber parameters. + +## Anti Pattern + +Renaming a publisher parameter to look prettier in the subscriber. The build breaks immediately. More insidiously, a parameter name that happens to match by coincidence in one event publisher but not in a similar one will compile in some versions of BC and fail in others when the publisher signature evolves. diff --git a/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.bad.al b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.bad.al new file mode 100644 index 0000000..4328ce2 --- /dev/null +++ b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.bad.al @@ -0,0 +1,13 @@ +tableextension 50211 "Sample FieldCaption Bad" extends Customer +{ + procedure ConfirmAndAnnounce(): Boolean + var + UpdateLocationQst: Label 'Update %1?'; + UpdatedMsg: Label 'Updated %1.'; + begin + if not Confirm(UpdateLocationQst, true, FieldName("Location Code")) then + exit(false); + Message(UpdatedMsg, TableName()); + exit(true); + end; +} diff --git a/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.good.al b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.good.al new file mode 100644 index 0000000..449b98d --- /dev/null +++ b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.good.al @@ -0,0 +1,13 @@ +tableextension 50210 "Sample FieldCaption Good" extends Customer +{ + procedure ConfirmAndAnnounce(): Boolean + var + UpdateLocationQst: Label 'Update %1?'; + UpdatedMsg: Label 'Updated %1.'; + begin + if not Confirm(UpdateLocationQst, true, FieldCaption("Location Code")) then + exit(false); + Message(UpdatedMsg, TableCaption()); + exit(true); + end; +} diff --git a/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md new file mode 100644 index 0000000..a74f1aa --- /dev/null +++ b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [fieldcaption, fieldname, tablecaption, tablename, translation, message, error] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use FieldCaption/TableCaption (not FieldName/TableName) in user-facing text + +## Description + +`FieldName` and `TableName` return the developer-facing identifier of a field or table — a fixed English string used in metadata and in code. `FieldCaption` and `TableCaption` return the translated, user-facing label declared by the field's or table's `Caption` property. When the value is embedded in a `Message`, `Error`, `Confirm`, or any other string shown to a user, the caption is the correct source. Otherwise the user sees the English internal name regardless of locale, and any caption change must be re-applied at every call site instead of being picked up from the single point of definition. + +## Best Practice + +Reach for `FieldCaption("Location Code")` and `TableCaption()` whenever the value flows into a UI primitive. The same rule applies to format parameters: `Error(SomeErr, FieldCaption("Status"), TableCaption(), "Status")` rather than `Error(SomeErr, FieldName("Status"), TableName(), "Status")`. The captions follow the user's language; the names do not. + +See sample: `fieldcaption-not-fieldname-in-user-messages.good.al`. + +## Anti Pattern + +`Message('Updated %1', TableName())` or `Confirm(UpdateLocationQst, true, FieldName("Location Code"))`. The user sees the English internal name in every locale, and any future rename of the caption fails to reach the message. + +See sample: `fieldcaption-not-fieldname-in-user-messages.bad.al`. diff --git a/microsoft/knowledge/style/file-name-object-type-pattern.md b/microsoft/knowledge/style/file-name-object-type-pattern.md new file mode 100644 index 0000000..dd8e0f1 --- /dev/null +++ b/microsoft/knowledge/style/file-name-object-type-pattern.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: style +keywords: [file-name, object-type, suffix, naming-convention] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Name AL source files `..al` + +## Description + +Each AL source file holds a single object, and the file name is expected to be of the form `..al` — `CustomerCard.Page.al`, `PostSalesInvoice.Codeunit.al`, `NoSeriesTests.Codeunit.al`, `SalesHeader.TableExt.al`. The pattern makes object types greppable from a file listing and lets tooling — symbol search, project explorers, code generators — locate objects without parsing the AL source. Snake-case, lowercase-only, or type-less file names (`customer_page.al`, `tests_noSeries.al`, `PostSalesInvoiceLogic.al`) all break that contract. + +## Best Practice + +Use PascalCase for the object portion, no spaces, no underscores; the type segment is one of the AL object-type names — `Page`, `Codeunit`, `Table`, `TableExt`, `Report`, `Query`, `XmlPort`, `Enum`, `EnumExt`, `Interface`, `PermissionSet`, `PageExt`, `ReportExt`. The object portion should echo the object's name as it appears in AL. + +See sample (file-naming pattern is structural; no AL sample shipped here). + +## Anti Pattern + +`customer_page.al`, `PostSalesInvoiceLogic.al`, `tests_noSeries.al`. The first uses snake_case and lower-case; the second omits the type segment entirely; the third inverts the order and uses mixed casing. All three break grep, symbol search, and the implicit map between file system and AL object table. diff --git a/microsoft/knowledge/style/follow-api-page-naming-rules.bad.al b/microsoft/knowledge/style/follow-api-page-naming-rules.bad.al deleted file mode 100644 index 155d263..0000000 --- a/microsoft/knowledge/style/follow-api-page-naming-rules.bad.al +++ /dev/null @@ -1,22 +0,0 @@ -page 51103 "Style Sample ApiPage Bad" -{ - PageType = API; - APIPublisher = 'Contoso-App'; // hyphen not allowed - APIGroup = 'app_1'; // underscore not allowed - APIVersion = 'v2'; // missing minor version - EntityName = 'customers'; // should be singular - EntitySetName = 'customer'; // should be plural - SourceTable = Customer; - // DelayedInsert omitted; composite-key inserts misbehave - - layout - { - area(Content) - { - repeater(Group) - { - field(number; Rec."No.") { } - } - } - } -} diff --git a/microsoft/knowledge/style/follow-api-page-naming-rules.md b/microsoft/knowledge/style/follow-api-page-naming-rules.md deleted file mode 100644 index a203d38..0000000 --- a/microsoft/knowledge/style/follow-api-page-naming-rules.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [api-page, apiversion, entityname, entitysetname, apipublisher, apigroup, delayedinsert] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# API pages follow strict naming and property rules that differ from regular pages - -## Description - -Pages declared `PageType = API` are exposed through the OData API surface. The platform enforces a set of conventions that regular pages do not share: `APIPublisher`, `APIGroup`, `EntityName`, and `EntitySetName` must be camelCase alphanumeric only — no spaces, hyphens, or underscores. `APIVersion` must match the pattern `vX.Y` (for example `v2.0`) or the literal `beta`. `EntityName` is the singular form (`customer`); `EntitySetName` is the plural (`customers`). `DelayedInsert = true` is effectively required for the OData insert workflow to behave correctly on composite keys. These rules are platform-enforced and tooling-enforced; violations produce runtime errors or consumer-visible inconsistencies rather than soft warnings. - -## Best Practice - -For every API page: camelCase alphanumeric API properties; `APIVersion` as `vX.Y` or `beta`; singular `EntityName` and plural `EntitySetName`; `DelayedInsert = true`. Keep these properties together near the top of the page definition so reviewers can check the set at a glance. - -See sample: `follow-api-page-naming-rules.good.al`. - -## Anti Pattern - -`APIPublisher = 'Contoso-App'` (hyphen rejected), `EntityName = 'customers'` and `EntitySetName = 'customer'` (swapped), `APIVersion = 'v2'` (missing minor version), `DelayedInsert` omitted. Each violation surfaces only when a consumer exercises the endpoint. - -See sample: `follow-api-page-naming-rules.bad.al`. diff --git a/microsoft/knowledge/style/function-call-parentheses-required.bad.al b/microsoft/knowledge/style/function-call-parentheses-required.bad.al new file mode 100644 index 0000000..2677716 --- /dev/null +++ b/microsoft/knowledge/style/function-call-parentheses-required.bad.al @@ -0,0 +1,11 @@ +codeunit 50213 "Sample Parens Bad" +{ + procedure Run() + var + Customer: Record Customer; + begin + Customer.Init; + if Customer.FindFirst then + Customer.Modify; + end; +} diff --git a/microsoft/knowledge/style/function-call-parentheses-required.good.al b/microsoft/knowledge/style/function-call-parentheses-required.good.al new file mode 100644 index 0000000..53f6f85 --- /dev/null +++ b/microsoft/knowledge/style/function-call-parentheses-required.good.al @@ -0,0 +1,11 @@ +codeunit 50212 "Sample Parens Good" +{ + procedure Run() + var + Customer: Record Customer; + begin + Customer.Init(); + if Customer.FindFirst() then + Customer.Modify(); + end; +} diff --git a/microsoft/knowledge/style/function-call-parentheses-required.md b/microsoft/knowledge/style/function-call-parentheses-required.md new file mode 100644 index 0000000..31fbaf8 --- /dev/null +++ b/microsoft/knowledge/style/function-call-parentheses-required.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [parentheses, function-call, method-call, aa0008, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Always write parentheses on procedure calls (CodeCop AA0008) + +## Description + +AL allows a parameterless procedure to be called without parentheses — `Customer.Init` instead of `Customer.Init()` — and the result is syntactically identical at runtime. CodeCop AA0008 still flags the parenthesis-less form. The reason is twofold: written without parentheses, a procedure call is visually indistinguishable from a property read, which makes BC code harder to scan; and the same identifier may exist as both a property and a procedure on different objects, so the parentheses are the only local signal that this is a call. The rule applies to every parameterless invocation, including `Init`, `Insert`, `Modify`, `Delete`, `DeleteAll`, `FindFirst`, `FindSet`, `Next`, `Get`, `CalcFields`, and user-defined procedures. + +## Best Practice + +Always write `()` on a procedure call, even when it takes no arguments: `Customer.Init();`, `TempBuffer.DeleteAll();`, `if Customer.FindFirst() then …`. The same applies inside expressions and as a condition. + +See sample: `function-call-parentheses-required.good.al`. + +## Anti Pattern + +`Customer.Init;`, `TempBuffer.DeleteAll;`, `if Customer.FindFirst then …`. Every one of those is an AA0008 violation. Reviewers should treat a parameterless procedure name appearing without parentheses as a defect, even though the compiler accepts it. + +See sample: `function-call-parentheses-required.bad.al`. diff --git a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.bad.al b/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.bad.al deleted file mode 100644 index b114696..0000000 --- a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.bad.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 51107 "Style Sample LabelProps Bad" -{ - procedure Example() - var - // Two placeholders, no Comment. The translator has to guess which - // identifier maps to %1 and which to %2. - CustomerLocationErr: Label 'Customer %1 not found in %2.'; - // URL without Locked: enters the localization pipeline, may be translated. - HttpsUrlLbl: Label 'https://example.com'; - CustomerNo: Code[20]; - LocationCode: Code[10]; - begin - Error(CustomerLocationErr, CustomerNo, LocationCode); - end; -} diff --git a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.good.al b/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.good.al deleted file mode 100644 index d462a3f..0000000 --- a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 51106 "Style Sample LabelProps Good" -{ - procedure Example() - var - CustomerNotFoundErr: Label 'Customer %1 does not exist for document %2.', - Comment = '%1 = Customer No., %2 = Document No.'; - HttpsProtocolTok: Label 'HTTPS', Locked = true; - ShortDescLbl: Label 'Description text', MaxLength = 50; - CustomerNo: Code[20]; - DocumentNo: Code[20]; - begin - Error(CustomerNotFoundErr, CustomerNo, DocumentNo); - end; -} diff --git a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.md b/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.md deleted file mode 100644 index 9064608..0000000 --- a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [label, placeholder, comment, locked, maxlength, localization] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Label placeholders need a Comment; locked strings need Locked = true - -## Description - -AL Labels accept optional properties — `Comment`, `Locked`, `MaxLength` — that travel with the string to localization. The Comment is the translator's only signal for what `%1` and `%2` mean; without it, `'Document %1 has errors in %2.'` translates unpredictably because the translator has to guess whether %1 is a document number, document type, or document name. `Locked = true` marks a string as non-translatable — URLs, JSON keys, short command tokens — and keeps the localization pipeline from translating literals that must stay verbatim. `MaxLength` limits how much of the label survives truncation. The Comment is required whenever placeholders are not self-evident; Locked is required on any non-text value. - -## Best Practice - -For placeholders, write `Comment = '%1 = Customer No., %2 = Document Type'` alongside the Label. For URLs, HTTP methods, JSON keys, and similar literals, set `Locked = true` and use the `Tok` suffix (see `apply-approved-label-suffixes`). For captions with a tight visual budget, set `MaxLength` to the enforceable length. When the placeholder meaning is obvious (`'Customer %1 not found.'`) the Comment is optional. - -See sample: `include-comment-on-labels-with-placeholders.good.al`. - -## Anti Pattern - -`CustomerLocationErr: Label 'Customer %1 not found in %2.';` with no Comment — translators will not know which identifier maps to which placeholder. `HttpsUrl: Label 'https://example.com';` with no Locked — the URL enters the localization pipeline and may be translated into a broken address. - -See sample: `include-comment-on-labels-with-placeholders.bad.al`. diff --git a/microsoft/knowledge/style/label-comment-explains-placeholders.bad.al b/microsoft/knowledge/style/label-comment-explains-placeholders.bad.al new file mode 100644 index 0000000..7f5893b --- /dev/null +++ b/microsoft/knowledge/style/label-comment-explains-placeholders.bad.al @@ -0,0 +1,11 @@ +codeunit 50203 "Sample Label Comment Bad" +{ + var + DocumentErrorErr: Label 'Document %1 has errors in %2.'; + ValidationErr: Label 'Field %1 in table %2 contains invalid value %3.'; + + procedure Validate(DocNo: Code[20]; Loc: Code[10]) + begin + Error(DocumentErrorErr, DocNo, Loc); + end; +} diff --git a/microsoft/knowledge/style/label-comment-explains-placeholders.good.al b/microsoft/knowledge/style/label-comment-explains-placeholders.good.al new file mode 100644 index 0000000..7318333 --- /dev/null +++ b/microsoft/knowledge/style/label-comment-explains-placeholders.good.al @@ -0,0 +1,12 @@ +codeunit 50202 "Sample Label Comment Good" +{ + var + CustomerNotFoundErr: Label 'Customer %1 does not exist for sales document %2.', Comment = '%1 = Customer No., %2 = Sales Header No.'; + ValidationErr: Label 'Field %1 in table %2 contains invalid value %3.', Comment = '%1 = Field Name, %2 = Table Caption, %3 = Field Value'; + CustomerSimpleLbl: Label 'Customer %1'; + + procedure Validate(CustNo: Code[20]; DocNo: Code[20]) + begin + Error(CustomerNotFoundErr, CustNo, DocNo); + end; +} diff --git a/microsoft/knowledge/style/label-comment-explains-placeholders.md b/microsoft/knowledge/style/label-comment-explains-placeholders.md new file mode 100644 index 0000000..43d9c1f --- /dev/null +++ b/microsoft/knowledge/style/label-comment-explains-placeholders.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [label, comment, placeholder, strsubstno, translation, aa0470] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Document each Label placeholder with the Comment parameter + +## Description + +`Label` and `TextConst` strings that contain placeholders (`%1`, `%2`, …) need a `Comment` parameter that names what each placeholder is. Translators do not see the call site, so without the Comment they cannot disambiguate `'Customer %1 not found in %2.'` — is `%2` a location code, a posting date, a company name? The pattern is `Comment = '%1 = , %2 = '`. The Comment is not required when the placeholder meaning is obvious from the surrounding text — `'Customer %1'` is unambiguously a Customer No. — but for any non-trivial label the Comment is a hard requirement. + +## Best Practice + +Write the Comment in the form `'%1 = Customer No., %2 = Sales Header No.'` — one entry per placeholder, matched by ordinal, named in the vocabulary of the BC domain. When the label is reused across multiple call sites, the Comment names the canonical meaning all call sites must conform to. + +See sample: `label-comment-explains-placeholders.good.al`. + +## Anti Pattern + +A label with two or more placeholders and no Comment, leaving the translator to guess. Equally bad is a Comment that only restates the placeholders (`'%1 and %2 are values'`) without naming what they are. Both fail in translation: the localized string ends up grammatically or semantically wrong, and the bug surfaces only in a non-English tenant. + +See sample: `label-comment-explains-placeholders.bad.al`. diff --git a/microsoft/knowledge/style/label-locked-for-non-translatable.bad.al b/microsoft/knowledge/style/label-locked-for-non-translatable.bad.al new file mode 100644 index 0000000..0ec72e8 --- /dev/null +++ b/microsoft/knowledge/style/label-locked-for-non-translatable.bad.al @@ -0,0 +1,7 @@ +codeunit 50205 "Sample Locked Label Bad" +{ + var + HttpsUrl: Label 'https://example.com'; + GetVerbTok: Label 'GET'; + JsonTypeLbl: Label 'application/json'; +} diff --git a/microsoft/knowledge/style/label-locked-for-non-translatable.good.al b/microsoft/knowledge/style/label-locked-for-non-translatable.good.al new file mode 100644 index 0000000..02b35d9 --- /dev/null +++ b/microsoft/knowledge/style/label-locked-for-non-translatable.good.al @@ -0,0 +1,8 @@ +codeunit 50204 "Sample Locked Label Good" +{ + var + GetMethodTok: Label 'GET', Locked = true; + ContentTypeJsonTok: Label 'application/json', Locked = true; + ApiBaseUrlTok: Label 'https://api.contoso.com/v1', Locked = true; + TelemetryStartTxt: Label 'Operation started for %1.', Locked = true; +} diff --git a/microsoft/knowledge/style/label-locked-for-non-translatable.md b/microsoft/knowledge/style/label-locked-for-non-translatable.md new file mode 100644 index 0000000..77f054b --- /dev/null +++ b/microsoft/knowledge/style/label-locked-for-non-translatable.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [label, locked, translation, token, url, json, xml] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Set `Locked = true` on Labels that must not be translated + +## Description + +A `Label` is by default surfaced to translators and rewritten per locale. That is wrong for strings that are not natural language: HTTP verbs (`GET`, `PUT`), URL fragments, JSON/XML snippets, content-type strings, GUIDs, application keys, and field tokens used by integrations. Translating these breaks the integration the moment a non-English tenant runs the code. The `Locked = true` parameter on the Label declaration tells the translation pipeline to keep the string verbatim, and signals to reviewers that the value is part of a wire-level contract rather than display text. + +## Best Practice + +Pair `Locked = true` with the `Tok` suffix for short tokens (`GetMethodTok: Label 'GET', Locked = true;`) and with the `Txt` suffix for telemetry strings that contain format placeholders but should not be localized. The `Locked` parameter and the `Tok` / `Txt` suffix together make the intent unambiguous. + +See sample: `label-locked-for-non-translatable.good.al`. + +## Anti Pattern + +`HttpsUrl: Label 'https://example.com';` or `ContentTypeTok: Label 'application/json';` declared without `Locked = true`. The translator localizes them, the integration fails in production for the affected tenant, and the failure is invisible in the developer's English-locale tests. + +See sample: `label-locked-for-non-translatable.bad.al`. diff --git a/microsoft/knowledge/style/label-suffix-approved-list.bad.al b/microsoft/knowledge/style/label-suffix-approved-list.bad.al new file mode 100644 index 0000000..6b227de --- /dev/null +++ b/microsoft/knowledge/style/label-suffix-approved-list.bad.al @@ -0,0 +1,14 @@ +codeunit 50201 "Sample Label Suffix Bad" +{ + var + CannotDeleteLine: Label 'Cannot delete this line.'; + Text000: Label 'Update complete'; + UpdateLocation: Label 'Update location?'; + WrongSuffixTok: Label 'Customer %1 not found.'; + + procedure ShowMessages() + begin + Error(WrongSuffixTok, '10000'); + Message(Text000); + end; +} diff --git a/microsoft/knowledge/style/label-suffix-approved-list.good.al b/microsoft/knowledge/style/label-suffix-approved-list.good.al new file mode 100644 index 0000000..f3ec561 --- /dev/null +++ b/microsoft/knowledge/style/label-suffix-approved-list.good.al @@ -0,0 +1,15 @@ +codeunit 50200 "Sample Label Suffix Good" +{ + var + UpdateCompleteMsg: Label 'Update complete.'; + CustomerNotFoundErr: Label 'Customer %1 does not exist.'; + DeleteRecordQst: Label 'Delete this record?'; + CustomerNameLbl: Label 'Customer Name'; + GetMethodTok: Label 'GET', Locked = true; + TelemetryStartedTxt: Label 'Operation started for customer %1.', Locked = true; + + procedure ShowMessage() + begin + Message(UpdateCompleteMsg); + end; +} diff --git a/microsoft/knowledge/style/label-suffix-approved-list.md b/microsoft/knowledge/style/label-suffix-approved-list.md new file mode 100644 index 0000000..8e937f3 --- /dev/null +++ b/microsoft/knowledge/style/label-suffix-approved-list.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [label, textconst, suffix, aa0074, codecop, msg, err, qst, lbl, tok] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use approved suffixes on Label and TextConst names (CodeCop AA0074) + +## Description + +CodeCop AA0074 flags `Label` and `TextConst` identifiers that do not end with an approved usage suffix. The suffix signals at the call site how the text is consumed and what translation behaviour it should get. The approved suffixes and their intended usage are: `Msg` for text shown via `Message()`; `Err` for text passed to `Error()`; `Qst` for text used with `Confirm` or `StrMenu`; `Lbl` for captions and tooltips; `Tok` for short tokens such as `'GET'`, `'PUT'`, `'HTTPS'`, GUIDs, or JSON/XML snippets that are not translated (typically with `Locked = true`); and `Txt` for general text including telemetry messages. A `Label` named `Text000` or `CannotDeleteLine` without a suffix violates the rule, regardless of how readable the prose is. + +## Best Practice + +Pick the suffix that matches the call where the label is consumed: `UpdateCompleteMsg` for `Message(...)`, `CustomerNotFoundErr` for `Error(...)`, `DeleteRecordQst` for `Confirm(...)`, `CustomerNameLbl` for tooltips and captions, `GetMethodTok` for locked tokens, `TelemetryDataTxt` for telemetry payloads. Suffix choices between `Tok`, `Lbl`, `Txt`, and `Msg` are judgment calls when the suffix is valid for the usage — what matters is that the suffix is on the approved list and matches the actual call. + +See sample: `label-suffix-approved-list.good.al`. + +## Anti Pattern + +A `Label` declared with no suffix (`CannotDeleteLine: Label '…';`), a generic name (`Text000: Label '…';`), or a suffix that contradicts the usage (`WrongSuffixTok: Label 'Customer %1 not found.'` then passed to `Error()`). All three trip AA0074 or its reviewers and obscure the call-site contract. + +See sample: `label-suffix-approved-list.bad.al`. diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al b/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al new file mode 100644 index 0000000..83d5994 --- /dev/null +++ b/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al @@ -0,0 +1,14 @@ +codeunit 50245 "Sample Upper Keywords Bad" +{ + procedure Walk(VAR Customer: Record Customer) + VAR + Found: Boolean; + BEGIN + IF Customer.FindSet() THEN + REPEAT + Found := TRUE; + UNTIL Customer.Next() = 0; + IF Found THEN + EXIT; + END; +} diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.good.al b/microsoft/knowledge/style/lowercase-reserved-keywords.good.al new file mode 100644 index 0000000..25fb20e --- /dev/null +++ b/microsoft/knowledge/style/lowercase-reserved-keywords.good.al @@ -0,0 +1,14 @@ +codeunit 50244 "Sample Lower Keywords Good" +{ + procedure Walk(var Customer: Record Customer) + var + Found: Boolean; + begin + if Customer.FindSet() then + repeat + Found := true; + until Customer.Next() = 0; + if Found then + exit; + end; +} diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.md b/microsoft/knowledge/style/lowercase-reserved-keywords.md new file mode 100644 index 0000000..f14b973 --- /dev/null +++ b/microsoft/knowledge/style/lowercase-reserved-keywords.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [reserved-keyword, lowercase, aa0241, codecop, if, then, begin] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Reserved keywords are written in lowercase (CodeCop AA0241) + +## Description + +CodeCop AA0241 requires reserved AL keywords — `if`, `then`, `else`, `begin`, `end`, `var`, `procedure`, `local`, `internal`, `for`, `while`, `repeat`, `until`, `case`, `of`, `do`, `not`, `and`, `or`, `exit`, `break`, `skip`, `quit`, and the rest — to be lowercase. Old Navision and C/AL code used `IF…THEN…BEGIN…END` in uppercase, and that style still lingers in training data and legacy modules. New AL code is lowercase. The rule applies to keywords only — type names (`Record`, `Codeunit`, `Integer`), property names (`Caption`, `ToolTip`), and identifiers are unaffected. + +Test codeunits that retain legacy uppercase forms (`OPENEDIT`, `ASSERTERROR`, `VALUE`) are an accepted exception: the test framework historically uses those identifiers and rewriting them brings no benefit. The rule applies to new code in modified lines, not to long-standing test patterns. + +## Best Practice + +Write keywords lowercase: `if Condition then begin … end;`, `repeat … until Found;`, `for i := 1 to N do …`. The standard AL formatter normalizes casing automatically. + +See sample: `lowercase-reserved-keywords.good.al`. + +## Anti Pattern + +`IF Condition THEN BEGIN DoSomething(); END;`, `REPEAT GetNext(); UNTIL Found;`. Uppercase keywords trip AA0241 and signal C/AL-era code that has not been modernized. + +See sample: `lowercase-reserved-keywords.bad.al`. diff --git a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.bad.al b/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.bad.al deleted file mode 100644 index 4931672..0000000 --- a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.bad.al +++ /dev/null @@ -1,22 +0,0 @@ -table 51113 "Style Sample Option Bad" -{ - fields - { - field(1; "Entry No."; Integer) { } - field(10; Priority; Option) - { - // Four members, three captions. Critical renders with no caption. - OptionMembers = Low,Medium,High,Critical; - OptionCaption = 'Low,Medium,High'; - } - field(20; Status; Option) - { - // Missing OptionCaption entirely. - OptionMembers = Open,Released,Pending; - } - } - keys - { - key(PK; "Entry No.") { Clustered = true; } - } -} diff --git a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.md b/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.md deleted file mode 100644 index 6f81865..0000000 --- a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [option, optionmembers, optioncaption, aa0221, aa0223, aa0224] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# OptionCaption must list exactly as many captions as OptionMembers - -## Description - -Option fields declare their values in `OptionMembers` and their localized display text in `OptionCaption`. The two lists are positionally paired — the Nth caption maps to the Nth member — and a mismatch either in count or in intent produces a field that renders blank for some values or shows the wrong caption for others. CodeCop rules AA0221, AA0223, and AA0224 flag the variants of this mistake: missing OptionCaption entirely on non-table-sourced option fields, OptionCaption with a different element count than OptionMembers, and OptionCaption content that does not correspond to the member names. - -## Best Practice - -Whenever OptionMembers is declared, declare OptionCaption with the same number of entries in the same order. For table-sourced option fields, the base table's caption applies and a per-page override is usually unnecessary — the rule applies to option fields defined in pages, reports, and non-table sources. - -See sample: `match-optioncaption-count-to-optionmembers.good.al`. - -## Anti Pattern - -`OptionMembers = Low,Medium,High,Critical;` paired with `OptionCaption = 'Low,Medium,High';` — three captions for four members. `Critical` rows render with the empty caption, or fall back to the member name, depending on where the option is displayed. - -See sample: `match-optioncaption-count-to-optionmembers.bad.al`. diff --git a/microsoft/knowledge/style/name-files-as-object-dot-type-dot-al.md b/microsoft/knowledge/style/name-files-as-object-dot-type-dot-al.md deleted file mode 100644 index 8dd4f56..0000000 --- a/microsoft/knowledge/style/name-files-as-object-dot-type-dot-al.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [file-name, convention, object-type, al-project] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Name AL files as `..al` - -## Description - -Business Central AL projects follow a consistent file-naming convention: the file name is the object's name, followed by a dot, followed by the object type (`Page`, `Codeunit`, `Table`, `Report`, `Enum`, etc.), followed by `.al`. `CustomerCard.Page.al`, `PostSalesInvoice.Codeunit.al`, `SalesLine.Table.al`. The convention produces an alphabetically-ordered folder that groups all of an entity's objects (`SalesLine.Table.al`, `SalesLine.TableExt.al`, `SalesLineCard.Page.al`) next to each other, and makes navigation by file name in large repos predictable. - -## Best Practice - -Match the file name to the object declaration: PascalCase name, type segment, `.al`. Use `TableExt`, `PageExt`, `EnumExt` for the corresponding extension types. When multiple objects share a file (generally discouraged), name the file after the primary object. - -## Anti Pattern - -`customer_page.al`, `PostSalesInvoiceLogic.al`, `tests_noSeries.al` — all three violate the convention. The first uses snake_case, the second adds a descriptive suffix after the object name, the third prefixes the type instead of suffixing it. Tooling that expects the convention (AL-Go scaffolding, navigation helpers, diff conventions) then misbehaves on these files. diff --git a/microsoft/knowledge/style/named-invocations-not-object-ids.bad.al b/microsoft/knowledge/style/named-invocations-not-object-ids.bad.al new file mode 100644 index 0000000..47e25d8 --- /dev/null +++ b/microsoft/knowledge/style/named-invocations-not-object-ids.bad.al @@ -0,0 +1,12 @@ +codeunit 50209 "Sample Named Invocations Bad" +{ + procedure ShowShipmentLines(var SalesShptLine: Record "Sales Shipment Line") + begin + Page.RunModal(525, SalesShptLine); + end; + + procedure RunInvoiceReport() + begin + Report.Run(206, true); + end; +} diff --git a/microsoft/knowledge/style/named-invocations-not-object-ids.good.al b/microsoft/knowledge/style/named-invocations-not-object-ids.good.al new file mode 100644 index 0000000..8586984 --- /dev/null +++ b/microsoft/knowledge/style/named-invocations-not-object-ids.good.al @@ -0,0 +1,12 @@ +codeunit 50208 "Sample Named Invocations Good" +{ + procedure ShowShipmentLines(var SalesShptLine: Record "Sales Shipment Line") + begin + Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine); + end; + + procedure RunInvoiceReport() + begin + Report.Run(Report::"Sales - Invoice", true); + end; +} diff --git a/microsoft/knowledge/style/named-invocations-not-object-ids.md b/microsoft/knowledge/style/named-invocations-not-object-ids.md new file mode 100644 index 0000000..413dfc2 --- /dev/null +++ b/microsoft/knowledge/style/named-invocations-not-object-ids.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [page, report, codeunit, runmodal, run, object-id, named-invocation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Call objects by name, not by numeric ID + +## Description + +`Page.RunModal`, `Report.Run`, `Codeunit.Run`, and the `Page::`, `Report::`, `Codeunit::`, `Table::`, `XmlPort::` selectors accept either a numeric ID or a named alias. The named form — `Page::"Posted Sales Shipment Lines"`, `Report::"Sales - Invoice"` — is the one to use. Numeric IDs are an implementation detail that change with renumbering, do not survive a rename, and carry no signal to a reader about what the call actually does. The compiler resolves named aliases at build time, so the named form is no slower than the numeric form. + +## Best Practice + +When invoking an object whose named alias is available in the same app (or in a dependency the current app already references), use the named form: `Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine)`, `Report.Run(Report::"Sales - Invoice", true)`. The same applies to `Codeunit.Run`, `XmlPort.Run`, `Query.Open`, and any platform method that takes an object reference. The named form makes diffs reviewable — a rename is visible — and makes log output and stack traces interpretable. + +See sample: `named-invocations-not-object-ids.good.al`. + +## Anti Pattern + +`Page.RunModal(525, …)` or `Report.Run(206, true)`. The numeric form is unreadable, fragile across renumbering, and breaks every search that looks for callers of a named object. + +See sample: `named-invocations-not-object-ids.bad.al`. diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al b/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al new file mode 100644 index 0000000..1803e1c --- /dev/null +++ b/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al @@ -0,0 +1,11 @@ +codeunit 50237 "Sample Single Stmt Bad" +{ + procedure Validate(IsAssemblyOutputLine: Boolean) + var + SalesLine: Record "Sales Line"; + begin + if IsAssemblyOutputLine then begin + SalesLine.TestField("Order Line No.", 0); + end; + end; +} diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al b/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al new file mode 100644 index 0000000..684a86c --- /dev/null +++ b/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al @@ -0,0 +1,10 @@ +codeunit 50236 "Sample Single Stmt Good" +{ + procedure Validate(IsAssemblyOutputLine: Boolean) + var + SalesLine: Record "Sales Line"; + begin + if IsAssemblyOutputLine then + SalesLine.TestField("Order Line No.", 0); + end; +} diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.md b/microsoft/knowledge/style/no-begin-end-around-single-statement.md new file mode 100644 index 0000000..d7665f7 --- /dev/null +++ b/microsoft/knowledge/style/no-begin-end-around-single-statement.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [begin, end, single-statement, aa0013, codecop, compound] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not wrap a single statement in `begin … end` (CodeCop AA0013) + +## Description + +CodeCop AA0013 flags `begin … end` blocks that contain exactly one statement. The compound-block syntax exists to group multiple statements as a unit; using it for a single statement adds two lines and a level of nesting without adding meaning. `if IsAssemblyOutputLine then begin TestField("Order Line No.", 0); end;` should be `if IsAssemblyOutputLine then TestField("Order Line No.", 0);` — one statement, no block. The same logic applies after `else`, `for`, `while`, and `repeat`. + +## Best Practice + +A single statement following `then`, `else`, `do`, or a case label is written on its own line, indented one level, with no `begin … end`. Use `begin … end` only when there are two or more statements to group. + +See sample: `no-begin-end-around-single-statement.good.al`. + +## Anti Pattern + +`if Cond then begin OneCall(); end;` — single statement wrapped in a block. AA0013 flags it. The reviewer signal is "a `begin` followed by exactly one statement before its `end`." + +See sample: `no-begin-end-around-single-statement.bad.al`. diff --git a/microsoft/knowledge/style/no-else-after-terminating-statement.bad.al b/microsoft/knowledge/style/no-else-after-terminating-statement.bad.al new file mode 100644 index 0000000..eefa888 --- /dev/null +++ b/microsoft/knowledge/style/no-else-after-terminating-statement.bad.al @@ -0,0 +1,13 @@ +codeunit 50243 "Sample Redundant Else Bad" +{ + procedure Validate(IsAdjmtBinCodeChanged: Boolean) + var + AdjmtBinErr: Label 'Adjustment bin code change not allowed.'; + BinCodeErr: Label 'Bin code change not allowed.'; + begin + if IsAdjmtBinCodeChanged then + Error(AdjmtBinErr) + else + Error(BinCodeErr); + end; +} diff --git a/microsoft/knowledge/style/no-else-after-terminating-statement.good.al b/microsoft/knowledge/style/no-else-after-terminating-statement.good.al new file mode 100644 index 0000000..15a0941 --- /dev/null +++ b/microsoft/knowledge/style/no-else-after-terminating-statement.good.al @@ -0,0 +1,12 @@ +codeunit 50242 "Sample No Else Good" +{ + procedure Validate(IsAdjmtBinCodeChanged: Boolean) + var + AdjmtBinErr: Label 'Adjustment bin code change not allowed.'; + BinCodeErr: Label 'Bin code change not allowed.'; + begin + if IsAdjmtBinCodeChanged then + Error(AdjmtBinErr); + Error(BinCodeErr); + end; +} diff --git a/microsoft/knowledge/style/no-else-after-terminating-statement.md b/microsoft/knowledge/style/no-else-after-terminating-statement.md new file mode 100644 index 0000000..76d7ede --- /dev/null +++ b/microsoft/knowledge/style/no-else-after-terminating-statement.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [else, exit, break, skip, quit, error, terminating, control-flow] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Omit `else` when the `then` branch ends with `exit`, `break`, `skip`, `quit`, or `error` + +## Description + +When the `then` branch of an `if` ends in a terminating statement — `exit`, `break`, `skip`, `quit`, or `error` — the `else` branch becomes the natural fall-through. `if Cond then exit; DoX();` and `if Cond then exit else DoX();` are equivalent, and the second form adds a layer of nesting that the reader has to mentally flatten. The same applies to `Error(...)`: `if IsAdjmtBinCodeChanged() then Error(AdjmtErr) else Error(BinErr);` is better written as `if IsAdjmtBinCodeChanged() then Error(AdjmtErr); Error(BinErr);` — the second `Error` is always reached when the first branch is not taken. + +## Best Practice + +Drop the `else` when the `then` branch unconditionally exits the procedure or the enclosing loop. The body that would have been inside `else` becomes the unindented continuation. + +See sample: `no-else-after-terminating-statement.good.al`. + +## Anti Pattern + +An `if … then Error(…) else Error(…)` pair where both branches terminate. The `else` is structural noise — the reader cannot tell at a glance whether it exists to handle an actual continuation or simply mirrors the `then`. The fix is to drop `else` and let the second `Error` fall through naturally. + +See sample: `no-else-after-terminating-statement.bad.al`. diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al b/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al new file mode 100644 index 0000000..b2f8295 --- /dev/null +++ b/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al @@ -0,0 +1,11 @@ +codeunit 50231 "Sample No Space Paren Bad" +{ + procedure Lookup(CustomerNo: Code[20]) + var + Customer: Record Customer; + GreetingMsg: Label 'Hello %1'; + begin + if Customer.Get ( CustomerNo ) then + Message ( GreetingMsg, Customer.Name ); + end; +} diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al b/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al new file mode 100644 index 0000000..eb16dc3 --- /dev/null +++ b/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al @@ -0,0 +1,11 @@ +codeunit 50230 "Sample No Space Paren Good" +{ + procedure Lookup(CustomerNo: Code[20]) + var + Customer: Record Customer; + GreetingMsg: Label 'Hello %1'; + begin + if Customer.Get(CustomerNo) then + Message(GreetingMsg, Customer.Name); + end; +} diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.md b/microsoft/knowledge/style/no-space-before-method-parenthesis.md new file mode 100644 index 0000000..d7a2f69 --- /dev/null +++ b/microsoft/knowledge/style/no-space-before-method-parenthesis.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [spacing, parenthesis, method-call, aa0002, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# No space between a method name and its opening parenthesis (CodeCop AA0002) + +## Description + +CodeCop AA0002 forbids whitespace between a procedure/method name and its `(`. `Customer.Get(CustomerNo)` is correct; `Customer.Get (CustomerNo)` is not. The rule applies to user-defined procedures, system methods (`Insert`, `FindFirst`, `CalcFields`), trigger-style invocations, and the parenthesised cast/conversion forms (`Format(Value)`, `CopyStr(Source, 1, 10)`). The whitespace between `(` and the first argument, and between the last argument and `)`, is also forbidden by the same rule. + +## Best Practice + +`Customer.Get(CustomerNo)`, `Customer.SetFilter("No.", '%1', '*A*')`, `Message(GreetingMsg, UserName)`. The standard AL formatter enforces this automatically. + +See sample: `no-space-before-method-parenthesis.good.al`. + +## Anti Pattern + +`Customer.Get ( CustomerNo )`, `Message ( GreetingMsg, UserName )`. Both trip AA0002 and read as if the call had an extra unnamed parameter — a small but persistent friction every reader pays. + +See sample: `no-space-before-method-parenthesis.bad.al`. diff --git a/microsoft/knowledge/style/object-name-30-char-limit.md b/microsoft/knowledge/style/object-name-30-char-limit.md new file mode 100644 index 0000000..f0e96ee --- /dev/null +++ b/microsoft/knowledge/style/object-name-30-char-limit.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: style +keywords: [object-name, length, prefix, affix, 30-characters, appsource] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep object names within the 30-character platform limit + +## Description + +Business Central object names — for tables, pages, codeunits, reports, queries, XML ports, enums, and permission sets — are limited to 30 characters in total. AppSource and per-tenant extensions also have to carry a mandatory prefix or affix (typically 3–4 characters), which leaves roughly 26 characters for the descriptive part of the name. Names hitting the 30-character ceiling are routinely rejected at publish time, and over-aggressive abbreviation to fit (`CustLE`, `SIPoster`, `SalesInv`) makes the object name opaque to reviewers and to anyone reading dependency lists. The right move is to plan name length around the budget — descriptive base + prefix — not to discover the limit during AppSource validation. + +## Best Practice + +Choose a clear, descriptive name in the 20–26-character range and reserve the remaining characters for the mandatory app prefix. `"Customer Ledger Entry"`, `"Sales Invoice Posting"`, `"Sales Invoice"` are descriptive and well under the budget. When you genuinely need to abbreviate, prefer abbreviations that are already established in BC (`Cust.`, `Vend.`, `Gen. Jnl.`, `WHSE`) over ad-hoc shortenings. + +## Anti Pattern + +Names like `"CustLE"` or `"SIPoster"` that abbreviate beyond comprehensibility, or names like `"Customer Ledger Entry Posting Helper Codeunit"` that breach 30 characters and force a rename during publish. diff --git a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al new file mode 100644 index 0000000..9d5269c --- /dev/null +++ b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al @@ -0,0 +1,17 @@ +table 50255 "Sample OptionCaption Bad" +{ + fields + { + field(1; Status; Option) + { + Caption = 'Status'; + OptionMembers = Open,Released,Pending; + } + field(2; Priority; Option) + { + Caption = 'Priority'; + OptionMembers = Low,Medium,High,Critical; + OptionCaption = 'Low,Medium,High'; + } + } +} diff --git a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.good.al b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al similarity index 55% rename from microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.good.al rename to microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al index fda6fa3..d0e9866 100644 --- a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.good.al +++ b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al @@ -1,21 +1,18 @@ -table 51112 "Style Sample Option Good" +table 50254 "Sample OptionCaption Good" { fields { - field(1; "Entry No."; Integer) { } - field(10; Priority; Option) - { - OptionMembers = Low,Medium,High,Critical; - OptionCaption = 'Low,Medium,High,Critical'; - } - field(20; Status; Option) + field(1; Status; Option) { + Caption = 'Status'; OptionMembers = Open,Released,Pending; OptionCaption = 'Open,Released,Pending'; } - } - keys - { - key(PK; "Entry No.") { Clustered = true; } + field(2; Priority; Option) + { + Caption = 'Priority'; + OptionMembers = Low,Medium,High,Critical; + OptionCaption = 'Low,Medium,High,Critical'; + } } } diff --git a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md new file mode 100644 index 0000000..d961040 --- /dev/null +++ b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [optioncaption, option, member-count, aa0221, aa0223, aa0224] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Option fields need `OptionCaption`, and its element count must match `OptionMembers` (CodeCop AA0221/AA0223/AA0224) + +## Description + +CodeCop AA0221 requires an `OptionCaption` on every option-type field that is not sourced from a table column (table-sourced option fields inherit the captions of the underlying field). AA0223 and AA0224 add two integrity checks: the number of comma-separated entries in `OptionCaption` must equal the number of entries in `OptionMembers`, and each caption must align by position with its member. The position alignment is what the platform uses to translate option values — the `OptionMembers` list never changes per locale, the `OptionCaption` list does. A mismatch in count or order produces silent corruption: the option `Released` shows the caption that belongs to `Pending`, and the bug is locale-dependent. + +## Best Practice + +`OptionMembers = Open,Released,Pending;` and `OptionCaption = 'Open,Released,Pending';` — same count, same order. When adding a new member, update both lines in the same commit. + +See sample: `optioncaption-required-and-matches-membercount.good.al`. + +## Anti Pattern + +`OptionMembers = Open,Released,Pending;` with no `OptionCaption` at all (the user sees the raw English members and translation is impossible), or `OptionMembers = Low,Medium,High,Critical;` paired with `OptionCaption = 'Low,Medium,High';` — count mismatch, `Critical` displays as blank or carries the wrong caption depending on platform version. + +See sample: `optioncaption-required-and-matches-membercount.bad.al`. diff --git a/microsoft/knowledge/style/page-name-must-match-source-table.md b/microsoft/knowledge/style/page-name-must-match-source-table.md new file mode 100644 index 0000000..b8706cb --- /dev/null +++ b/microsoft/knowledge/style/page-name-must-match-source-table.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: style +keywords: [page-name, source-table, misleading, naming] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A page or view name must describe the table it shows + +## Description + +A page (or filtered page View) whose name references one entity but whose `SourceTable` is a different entity misleads every consumer of the object's metadata. A page named `"Items with Negative Inventory"` that sources `"Stockkeeping Unit"` looks like a list of items in the search bar and in role explorer, but presents stockkeeping-unit fields and behaviour. The fix is either to rename the page to match the source table — `"Stockkeeping Units with Negative Inventory"` — or to change the source table to the entity the name promises. The choice depends on which the actual users are asking for; the constraint is that the two MUST agree. + +The rule extends to filtered Views declared inside a page: the `View` name should describe the filter applied to the page's existing source, not introduce a different entity. + +## Best Practice + +Read the page name out loud and ask: "If a user typed this into the search bar, would they expect to see rows from ``?" If the answer is no, rename one side or the other. The same check applies whenever the source table changes — the name has to follow. + +## Anti Pattern + +`page "Items with Negative Inventory" { SourceTable = "Stockkeeping Unit"; … }`. The Tell-Don't-Ask name asserts items; the source contradicts it. Reviewers should flag every mismatch they spot, even when both sides "make sense individually" — they have to agree. diff --git a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.bad.al b/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.bad.al deleted file mode 100644 index 947caa1..0000000 --- a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 51115 "Style Sample ErrorParams Bad" -{ - procedure Fail(CustomerNo: Code[20]) - var - CustomerNotFoundErr: Label 'Customer %1 does not exist.', Comment = '%1 = Customer No.'; - begin - // Pre-built Text to Error: translation skipped, telemetry opaque. - Error(StrSubstNo(CustomerNotFoundErr, CustomerNo)); - - // Concatenation: translation skipped, hard-coded delimiters baked in. - Error('Customer ' + CustomerNo + ' not found'); - end; -} diff --git a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.good.al b/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.good.al deleted file mode 100644 index 57a1775..0000000 --- a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.good.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 51114 "Style Sample ErrorParams Good" -{ - procedure Fail(CustomerNo: Code[20]; DocumentNo: Code[20]) - var - CustomerNotFoundErr: Label 'Customer %1 does not exist for document %2.', - Comment = '%1 = Customer No., %2 = Document No.'; - begin - // Label + arguments passed directly. Translations apply; telemetry classifies per field. - Error(CustomerNotFoundErr, CustomerNo, DocumentNo); - end; -} diff --git a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.md b/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.md deleted file mode 100644 index b7d758b..0000000 --- a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [error, label, strsubstno, concatenation, telemetry, aa0216, aa0217] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Pass Error parameters directly to the Label; do not pre-build with StrSubstNo or concatenation - -## Description - -`Error` accepts a Label and its substitution parameters directly (`Error(CustomerNotFoundErr, CustomerNo, DocumentNo)`). Pre-building the message via `StrSubstNo` and passing the resulting Text, or concatenating parts with `+` and passing the result, compiles but produces two distinct regressions. The localization pipeline can only translate the Label; a pre-built Text is passed through untouched, so non-English users see the English template. Platform telemetry inspects the Label's placeholder arguments for DataClassification; a pre-built Text is opaque, so PII in the arguments is logged verbatim (see `strsubstno-prebuild-breaks-error-telemetry-classification` in the privacy domain). - -## Best Practice - -Declare the Label with placeholders and pass arguments directly to Error: `Error(CustomerNotFoundErr, CustomerNo, DocumentNo)`. Use `Comment` on the Label to document each placeholder (see `include-comment-on-labels-with-placeholders`). `Error('')` is acceptable when the caller is responsible for the surfaced error. - -See sample: `pass-parameters-directly-to-error-no-strsubstno.good.al`. - -## Anti Pattern - -`Error(StrSubstNo(CustomerNotFoundErr, CustomerNo))` — loses translation. `Error(CustomerNotFoundErr + ': ' + CustomerNo)` — loses translation, concatenates hard-coded delimiters. `Error('Customer ' + CustomerNo + ' not found')` — uses no Label at all. - -See sample: `pass-parameters-directly-to-error-no-strsubstno.bad.al`. diff --git a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.bad.al b/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.bad.al deleted file mode 100644 index ba94df9..0000000 --- a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.bad.al +++ /dev/null @@ -1,17 +0,0 @@ -codeunit 51105 "Style Sample TempPrefix Bad" -{ - procedure BuildWorkingSet() - var - WIPBuffer: Record "Job WIP Buffer" temporary; - Customer: Record Customer; - begin - // Call sites read as persistent. A reviewer cannot tell at a glance - // whether DeleteAll hits the database or the in-memory buffer. - WIPBuffer.DeleteAll(); - if Customer.FindSet() then - repeat - WIPBuffer.Init(); - WIPBuffer.Insert(); - until Customer.Next() = 0; - end; -} diff --git a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.good.al b/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.good.al deleted file mode 100644 index 1f7d090..0000000 --- a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.good.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 51104 "Style Sample TempPrefix Good" -{ - procedure BuildWorkingSet() - var - TempJobWIPBuffer: Record "Job WIP Buffer" temporary; - Customer: Record Customer; - begin - // Every read site shows whether the variable is temporary. - TempJobWIPBuffer.DeleteAll(); - if Customer.FindSet() then - repeat - TempJobWIPBuffer.Init(); - TempJobWIPBuffer.Insert(); - until Customer.Next() = 0; - end; -} diff --git a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.md b/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.md deleted file mode 100644 index 36d8a6e..0000000 --- a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [temporary, record, variable, prefix, naming, temp] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Prefix temporary record variables with "Temp" - -## Description - -A `Record X temporary` variable behaves differently from a persistent Record variable of the same type: Insert/Modify/Delete mutate an in-memory buffer, not the underlying table. Code that mixes persistent and temporary variables of the same type is a recurring source of data-loss bugs — a helper that does `DeleteAll` on what the caller believed was a temporary buffer wipes the real table. The convention across Business Central is to prefix every temporary record variable with `Temp` (`TempJobWIPBuffer`, `TempSalesLine`, `TempCustomer`) so the distinction is visible at every read site, not only at the declaration. - -## Best Practice - -Prefix every temporary-record variable with `Temp`. The prefix goes on the variable name, not the type; the `temporary` keyword remains on the declaration. Matching the prefix against the declaration makes it a one-line check in code review: if the name starts with `Temp`, the declaration ends in `temporary`, and vice versa. - -See sample: `prefix-temporary-record-variables-with-temp.good.al`. - -## Anti Pattern - -`WIPBuffer: Record "Job WIP Buffer" temporary` — the variable reads like a persistent record in every call site below the declaration. A reviewer scanning a mutation call (`WIPBuffer.DeleteAll()`) cannot tell from the call site whether the effect is in-memory or production. - -See sample: `prefix-temporary-record-variables-with-temp.bad.al`. diff --git a/microsoft/knowledge/style/require-parentheses-on-function-calls.bad.al b/microsoft/knowledge/style/require-parentheses-on-function-calls.bad.al deleted file mode 100644 index 8a0dba6..0000000 --- a/microsoft/knowledge/style/require-parentheses-on-function-calls.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 51119 "Style Sample Parentheses Bad" -{ - procedure Example(var Customer: Record Customer) - var - TempBuffer: Record "Integer" temporary; - begin - // Parentheses omitted. The call site reads like a field access. - Customer.Init; - TempBuffer.DeleteAll; - if Customer.FindFirst then - ; - end; -} diff --git a/microsoft/knowledge/style/require-parentheses-on-function-calls.good.al b/microsoft/knowledge/style/require-parentheses-on-function-calls.good.al deleted file mode 100644 index 358ffe5..0000000 --- a/microsoft/knowledge/style/require-parentheses-on-function-calls.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 51118 "Style Sample Parentheses Good" -{ - procedure Example(var Customer: Record Customer) - var - TempBuffer: Record "Integer" temporary; - begin - Customer.Init(); - TempBuffer.DeleteAll(); - if Customer.FindFirst() then - ; - end; -} diff --git a/microsoft/knowledge/style/require-parentheses-on-function-calls.md b/microsoft/knowledge/style/require-parentheses-on-function-calls.md deleted file mode 100644 index 3c1916b..0000000 --- a/microsoft/knowledge/style/require-parentheses-on-function-calls.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [parentheses, function-call, aa0008, invocation] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Every function call carries parentheses, even with no arguments - -## Description - -AL allows `Customer.Init`, `TempBuffer.DeleteAll`, and `Customer.FindFirst` without trailing parentheses when the method takes no parameters. CodeCop rule AA0008 requires the parentheses anyway. The reason is readability: without `()`, the reader has to know the member is a method and not a property — an ambiguity that resolves differently for the platform's own APIs (FindFirst is a method; `Name` is a field). With `()`, the call site is visibly a method invocation and a simple grep for `Init(` or `DeleteAll(` finds every usage. - -## Best Practice - -Always write parentheses on method calls, even when empty: `Customer.Init()`, `TempBuffer.DeleteAll()`, `if Customer.FindFirst() then`. Apply the rule to platform methods and to user-defined procedures alike. - -See sample: `require-parentheses-on-function-calls.good.al`. - -## Anti Pattern - -`Customer.Init;`, `TempBuffer.DeleteAll;`, `if Customer.FindFirst then` — all three compile but obscure what is a call and what is a field access. The inconsistency compounds when the same codebase has both conventions. - -See sample: `require-parentheses-on-function-calls.bad.al`. diff --git a/microsoft/knowledge/style/single-space-after-not-operator.bad.al b/microsoft/knowledge/style/single-space-after-not-operator.bad.al new file mode 100644 index 0000000..c7143e7 --- /dev/null +++ b/microsoft/knowledge/style/single-space-after-not-operator.bad.al @@ -0,0 +1,11 @@ +codeunit 50233 "Sample Not Spacing Bad" +{ + procedure Check(): Boolean + var + Customer: Record Customer; + begin + if NOT Customer.IsEmpty() then + exit(true); + exit(false); + end; +} diff --git a/microsoft/knowledge/style/single-space-after-not-operator.good.al b/microsoft/knowledge/style/single-space-after-not-operator.good.al new file mode 100644 index 0000000..92d8f33 --- /dev/null +++ b/microsoft/knowledge/style/single-space-after-not-operator.good.al @@ -0,0 +1,11 @@ +codeunit 50232 "Sample Not Spacing Good" +{ + procedure Check(): Boolean + var + Customer: Record Customer; + begin + if not Customer.IsEmpty() then + exit(true); + exit(false); + end; +} diff --git a/microsoft/knowledge/style/single-space-after-not-operator.md b/microsoft/knowledge/style/single-space-after-not-operator.md new file mode 100644 index 0000000..c5d2077 --- /dev/null +++ b/microsoft/knowledge/style/single-space-after-not-operator.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [spacing, not, operator, aa0003, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Exactly one space between `not` and its argument (CodeCop AA0003) + +## Description + +CodeCop AA0003 requires exactly one space between the `not` operator and the expression it negates. `if not Customer.FindFirst() then …` is correct; `if not Customer.FindFirst() then …` (two spaces) and `if notCustomer.FindFirst() then …` (zero — which fails parsing anyway) are not. The rule is also the place where uppercase `NOT` is flagged in combination with CodeCop AA0241 (reserved keywords must be lowercase): `if NOT Condition then` is doubly wrong. + +## Best Practice + +`if not Condition then`, `if not Customer.IsEmpty() then`, `exit(not Result)`. One space, lowercase keyword, no parentheses around the bare boolean. + +See sample: `single-space-after-not-operator.good.al`. + +## Anti Pattern + +`if NOT condition then`, `if not condition then`, `if !condition then` (which is not even AL — `!` is not a negation operator in AL). All three either trip AA0003 / AA0241 or fail to compile. + +See sample: `single-space-after-not-operator.bad.al`. diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.bad.al b/microsoft/knowledge/style/single-space-around-binary-operators.bad.al new file mode 100644 index 0000000..2515d33 --- /dev/null +++ b/microsoft/knowledge/style/single-space-around-binary-operators.bad.al @@ -0,0 +1,12 @@ +codeunit 50229 "Sample Spaces Op Bad" +{ + procedure Compute(Amount: Decimal; Quantity: Decimal): Decimal + var + Price: Decimal; + begin + Price:=Amount*Quantity; + if (Amount>0)and(Quantity>0) then + exit(Price); + exit(0); + end; +} diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.good.al b/microsoft/knowledge/style/single-space-around-binary-operators.good.al new file mode 100644 index 0000000..55793d3 --- /dev/null +++ b/microsoft/knowledge/style/single-space-around-binary-operators.good.al @@ -0,0 +1,12 @@ +codeunit 50228 "Sample Spaces Op Good" +{ + procedure Compute(Amount: Decimal; Quantity: Decimal): Decimal + var + Price: Decimal; + begin + Price := Amount * Quantity; + if (Amount > 0) and (Quantity > 0) then + exit(Price); + exit(0); + end; +} diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.md b/microsoft/knowledge/style/single-space-around-binary-operators.md new file mode 100644 index 0000000..a09fef9 --- /dev/null +++ b/microsoft/knowledge/style/single-space-around-binary-operators.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [spacing, binary-operator, aa0001, codecop, formatting] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# One space on each side of every binary operator (CodeCop AA0001) + +## Description + +CodeCop AA0001 requires exactly one space on each side of every binary operator: assignment (`:=`), arithmetic (`+`, `-`, `*`, `/`, `mod`, `div`), comparison (`=`, `<>`, `<`, `<=`, `>`, `>=`), logical (`and`, `or`, `xor`), and string concatenation. `x:=1+2`, `Price:=Amount*Quantity`, `if a=b then`, and `if a and b then` all violate the rule. The rule applies to the binary use of `-` (subtraction); the unary minus (`-Profit`) takes no leading space. + +## Best Practice + +Write `x := 1 + 2`, `Price := Amount * Quantity`, `if a = b then`, `if a and b then`. The standard AL formatter inserts these spaces automatically; running `Alt+Shift+F` (Format Document) in the AL extension is the simplest way to bring an entire file into compliance. + +See sample: `single-space-around-binary-operators.good.al`. + +## Anti Pattern + +`x:=1+2;`, `Price:=Amount*Quantity;`, `if a=b then`, `if a and b then`. All trip AA0001. + +See sample: `single-space-around-binary-operators.bad.al`. diff --git a/microsoft/knowledge/style/temporary-variable-temp-prefix.bad.al b/microsoft/knowledge/style/temporary-variable-temp-prefix.bad.al new file mode 100644 index 0000000..62a1dbe --- /dev/null +++ b/microsoft/knowledge/style/temporary-variable-temp-prefix.bad.al @@ -0,0 +1,12 @@ +codeunit 50217 "Sample Temp Prefix Bad" +{ + procedure BuildBuffer(var SalesLine: Record "Sales Line" temporary) + var + WIPBuffer: Record "Job WIP Buffer" temporary; + begin + WIPBuffer.Init(); + WIPBuffer.Insert(); + SalesLine.Init(); + SalesLine.Insert(); + end; +} diff --git a/microsoft/knowledge/style/temporary-variable-temp-prefix.good.al b/microsoft/knowledge/style/temporary-variable-temp-prefix.good.al new file mode 100644 index 0000000..09123a9 --- /dev/null +++ b/microsoft/knowledge/style/temporary-variable-temp-prefix.good.al @@ -0,0 +1,12 @@ +codeunit 50216 "Sample Temp Prefix Good" +{ + procedure BuildBuffer(var TempSalesLine: Record "Sales Line" temporary) + var + TempJobWIPBuffer: Record "Job WIP Buffer" temporary; + begin + TempJobWIPBuffer.Init(); + TempJobWIPBuffer.Insert(); + TempSalesLine.Init(); + TempSalesLine.Insert(); + end; +} diff --git a/microsoft/knowledge/style/temporary-variable-temp-prefix.md b/microsoft/knowledge/style/temporary-variable-temp-prefix.md new file mode 100644 index 0000000..2211b4c --- /dev/null +++ b/microsoft/knowledge/style/temporary-variable-temp-prefix.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [temporary, temp, prefix, record-variable, naming] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefix temporary record variables with `Temp` + +## Description + +A `Record` variable declared with the `temporary` modifier behaves nothing like a normal record variable: it never touches the database, holds rows only for the lifetime of the variable, and is not visible to filters or queries on the underlying table. The BC convention is to make that difference visible at every call site by prefixing the variable name with `Temp` — `TempJobWIPBuffer`, `TempSalesLine`, `TempIntegerBuffer`. The convention is load-bearing for code review: when a reader sees `SalesLine.Insert()`, they expect a database write; when they see `TempSalesLine.Insert()`, they know it is an in-memory buffer. + +## Best Practice + +Every variable of type `Record X temporary` must start with `Temp`. The same applies to parameters: a procedure that receives a temporary record as a buffer names the parameter `TempBuffer`, `TempSalesLine`, and so on. The convention extends naturally to derived names — `TempJobWIPBufferCopy`, `TempSourceSalesLine` — anything that starts with `Temp` is in-memory. + +See sample: `temporary-variable-temp-prefix.good.al`. + +## Anti Pattern + +`WIPBuffer: Record "Job WIP Buffer" temporary;` reads at the call site as if it were a database operation: `WIPBuffer.Insert()` looks identical to a write to the underlying table. The reader has to scroll back to the declaration to discover that this is in-memory, every time. + +See sample: `temporary-variable-temp-prefix.bad.al`. diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al b/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al new file mode 100644 index 0000000..7fd03a1 --- /dev/null +++ b/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al @@ -0,0 +1,14 @@ +codeunit 50215 "Sample This Bad" +{ + procedure ProcessRecord(Customer: Record Customer) + var + Helper: Codeunit "Sample This Helper"; + begin + ValidateCustomer(Customer); + Helper.DoWork(); + end; + + local procedure ValidateCustomer(Customer: Record Customer) + begin + end; +} diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.good.al b/microsoft/knowledge/style/this-keyword-in-codeunits.good.al new file mode 100644 index 0000000..392c042 --- /dev/null +++ b/microsoft/knowledge/style/this-keyword-in-codeunits.good.al @@ -0,0 +1,14 @@ +codeunit 50214 "Sample This Good" +{ + procedure ProcessRecord(Customer: Record Customer) + var + Helper: Codeunit "Sample This Helper"; + begin + this.ValidateCustomer(Customer); + Helper.DoWork(this); + end; + + local procedure ValidateCustomer(Customer: Record Customer) + begin + end; +} diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.md b/microsoft/knowledge/style/this-keyword-in-codeunits.md new file mode 100644 index 0000000..ffcf5a1 --- /dev/null +++ b/microsoft/knowledge/style/this-keyword-in-codeunits.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [this, codeunit, self-reference, aa0248, scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use the `this` keyword for self-reference inside codeunits (CodeCop AA0248) + +## Description + +CodeCop AA0248 recommends prefixing self-references inside a codeunit with `this`. `this.ValidateCustomer(Customer)` is unambiguous: the call resolves to a procedure on the current codeunit, not to a local variable or a procedure on a passed-in object. Without the prefix, a reader of a 200-line procedure has to scan the whole codeunit to confirm whether `ValidateCustomer` is local. `this` also makes it possible to pass the current codeunit as an argument — `SomeOtherCodeunit.DoWork(this)` — which is the only way to expose the running codeunit instance to a collaborator. The rule applies only to codeunits, not to pages, reports, queries, or tables — those object types do not have a `this` reference in AL. + +## Best Practice + +Inside a codeunit, prefix calls to procedures and accesses to global variables on the same codeunit with `this.`, and pass `this` when an external codeunit needs a reference to the running instance. + +See sample: `this-keyword-in-codeunits.good.al`. + +## Anti Pattern + +Calling a codeunit-local procedure as a bare identifier (`ValidateCustomer(Customer)`) when other readings are possible. The ambiguity costs reading time on every encounter and grows with codeunit size. + +See sample: `this-keyword-in-codeunits.bad.al`. diff --git a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.good.al b/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al similarity index 53% rename from microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.good.al rename to microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al index 100dfcf..e6b356c 100644 --- a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.good.al +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al @@ -1,23 +1,21 @@ -page 51002 "UI Sample FieldTooltip Good" +page 50251 "Sample Tooltip Bad" { PageType = Card; SourceTable = Customer; - layout { area(Content) { group(General) { - field("Name"; Rec.Name) + field("No."; Rec."No.") { ApplicationArea = All; - ToolTip = 'Specifies the name of the customer.'; } - field("Balance (LCY)"; Rec."Balance (LCY)") + field(Amount; Rec."Balance (LCY)") { ApplicationArea = All; - ToolTip = 'Shows the current balance in the local currency.'; + ToolTip = ''; } } } diff --git a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.bad.al b/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al similarity index 51% rename from microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.bad.al rename to microsoft/knowledge/style/tooltip-required-on-page-fields.good.al index 3f7eec1..1816de5 100644 --- a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.bad.al +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al @@ -1,24 +1,22 @@ -page 51003 "UI Sample FieldTooltip Bad" +page 50250 "Sample Tooltip Good" { PageType = Card; SourceTable = Customer; - layout { area(Content) { group(General) { - field("Name"; Rec.Name) + field("No."; Rec."No.") { ApplicationArea = All; - // No "Specifies" opener, no period, a bare fragment. - ToolTip = 'The name of the customer'; + ToolTip = 'Specifies the number that identifies the customer.'; } - field("Balance (LCY)"; Rec."Balance (LCY)") + field(Amount; Rec."Balance (LCY)") { ApplicationArea = All; - ToolTip = 'Balance'; + ToolTip = 'Shows the total balance in local currency.'; } } } diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.md b/microsoft/knowledge/style/tooltip-required-on-page-fields.md new file mode 100644 index 0000000..fc5a3cb --- /dev/null +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [tooltip, page-field, aa0218, codecop, accessibility, specifies] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every page field needs a `ToolTip` (CodeCop AA0218) + +## Description + +CodeCop AA0218 requires a non-empty `ToolTip` property on every field control on a page. The tooltip is what users see on hover and is what screen readers announce; an empty or missing tooltip removes a piece of UI affordance that is part of BC's accessibility baseline. AppSource technical validation rejects pages with missing tooltips. The companion rules AA0219 and AA0220 push the wording further — tooltips should describe what the field shows, conventionally starting with `'Specifies …'`, though `'Shows …'` and similar variants are acceptable when they clearly describe the field's purpose. + +Acceptable exceptions: table fields inside `Upgrade`, `Migration`, `HybridBC14`, `HybridSL`, and `HybridGP` codeunits and tables are allowed to omit the tooltip — those types are not surfaced to users. + +## Best Practice + +Every field control on a regular page carries `ToolTip = 'Specifies …';` (or a clear alternative phrasing). Compose the text in the form "what this value shows" rather than "what the user does with it". + +See sample: `tooltip-required-on-page-fields.good.al`. + +## Anti Pattern + +A field control with no `ToolTip` property at all, or `ToolTip = '';`. AA0218 flags both; the hover state is blank and the screen reader has nothing to announce. + +See sample: `tooltip-required-on-page-fields.bad.al`. diff --git a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.bad.al b/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.bad.al deleted file mode 100644 index 5492368..0000000 --- a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.bad.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 51111 "Style Sample FieldCaption Bad" -{ - procedure Example(var SalesLine: Record "Sales Line") - var - UpdateLocationQst: Label 'Update the %1?', Comment = '%1 = field'; - begin - // FieldName/TableName return English identifiers. User with a non-English - // locale sees the English "Location Code" inside an otherwise translated dialog. - if not Confirm(UpdateLocationQst, true, SalesLine.FieldName("Location Code")) then - exit; - end; -} diff --git a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.good.al b/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.good.al deleted file mode 100644 index cf7693e..0000000 --- a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.good.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 51110 "Style Sample FieldCaption Good" -{ - procedure Example(var SalesLine: Record "Sales Line") - var - UpdateLocationQst: Label 'Update the %1?', Comment = '%1 = field caption'; - TableUpdatedMsg: Label 'Updated %1.', Comment = '%1 = table caption'; - begin - // Captions are localized for the current user's language. - if not Confirm(UpdateLocationQst, true, SalesLine.FieldCaption("Location Code")) then - exit; - Message(TableUpdatedMsg, SalesLine.TableCaption()); - end; -} diff --git a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.md b/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.md deleted file mode 100644 index 2bd8d6f..0000000 --- a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [fieldcaption, tablecaption, fieldname, tablename, localization, user-message] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use FieldCaption and TableCaption in user messages, not FieldName and TableName - -## Description - -`FieldName` and `TableName` return the object's internal identifier in English — the name the developer typed into the declaration. `FieldCaption` and `TableCaption` return the translated caption for the current user's language. In user-facing messages, errors, confirmations, and notifications, the two pairs diverge the moment the user is running a non-English locale: `FieldName("Location Code")` reads `Location Code` in every language, while `FieldCaption("Location Code")` reads the translated equivalent. Using the wrong one leaks the English identifier into a localized UI and defeats the product's translation work. - -## Best Practice - -In any string the user will read, use `FieldCaption()` and `TableCaption`. Reserve `FieldName` and `TableName` for diagnostic and telemetry contexts where the stable English identifier is preferable. The same rule applies to `XmlPort`, `Query`, and other objects with a caption/name pair. - -See sample: `use-fieldcaption-and-tablecaption-in-user-messages.good.al`. - -## Anti Pattern - -`Confirm(UpdateLocationQst, true, FieldName("Location Code"))`, `Message('Updated %1', TableName())` — both surface English identifiers to a user whose entire UI is in a different language. - -See sample: `use-fieldcaption-and-tablecaption-in-user-messages.bad.al`. diff --git a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.bad.al b/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.bad.al deleted file mode 100644 index 1065a25..0000000 --- a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 51109 "Style Sample NamedInvoke Bad" -{ - procedure Example(var SalesShptLine: Record "Sales Shipment Line") - begin - // Numeric ID. The reader has to look up 525 and 206 to know what is called. - // If either object is renumbered in a future release, this call silently retargets. - Page.RunModal(525, SalesShptLine); - Report.Run(206, true); - end; -} diff --git a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.good.al b/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.good.al deleted file mode 100644 index 634c202..0000000 --- a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.good.al +++ /dev/null @@ -1,9 +0,0 @@ -codeunit 51108 "Style Sample NamedInvoke Good" -{ - procedure Example(var SalesShptLine: Record "Sales Shipment Line") - begin - // Named invocation: reviewer sees the object, rename of 525 cannot retarget. - Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine); - Report.Run(Report::"Sales - Invoice", true); - end; -} diff --git a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.md b/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.md deleted file mode 100644 index db62cff..0000000 --- a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [object-id, page-run, report-run, codeunit-run, named-invocation] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Invoke objects by name, not by numeric ID - -## Description - -AL supports calling `Page.RunModal(525, ...)` or `Report.Run(206, ...)` with a bare numeric ID. The platform accepts the number, but the call site loses every signal that makes the code reviewable and refactor-safe: the reader cannot tell which object is being invoked without looking up 525 in the object catalog, and the renumbering of an object in a future release (legal in AL — IDs are not a stable contract) silently retargets the call to a different object. The `Page::"..."` / `Report::"..."` syntax compiles to the same runtime call but makes the target explicit and binds by name, which is the stable identity. - -## Best Practice - -Write `Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine)` and `Report.Run(Report::"Sales - Invoice", true)`. Apply the same rule to `Codeunit.Run`, `XmlPort.Run`, and similar runtime invocations. Reserve numeric IDs for diagnostic tooling that genuinely needs them. - -See sample: `use-named-invocations-instead-of-object-ids.good.al`. - -## Anti Pattern - -`Page.RunModal(525, SalesShptLine);` — the reader has no idea what page 525 is without a lookup, and a future rename of page 525 or renumber of "Posted Sales Shipment Lines" produces a silent mismatch. - -See sample: `use-named-invocations-instead-of-object-ids.bad.al`. diff --git a/microsoft/knowledge/style/use-this-keyword-in-codeunits.bad.al b/microsoft/knowledge/style/use-this-keyword-in-codeunits.bad.al deleted file mode 100644 index b35982d..0000000 --- a/microsoft/knowledge/style/use-this-keyword-in-codeunits.bad.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 51117 "Style Sample ThisKeyword Bad" -{ - procedure ProcessRecord(var Customer: Record Customer) - begin - // Ambiguous: is ValidateCustomer a local, a global, or a method on - // another codeunit in scope? - ValidateCustomer(Customer); - - // No way to pass the current codeunit without `this`. - end; - - local procedure ValidateCustomer(var Customer: Record Customer) - begin - end; -} diff --git a/microsoft/knowledge/style/use-this-keyword-in-codeunits.good.al b/microsoft/knowledge/style/use-this-keyword-in-codeunits.good.al deleted file mode 100644 index 6a6bf94..0000000 --- a/microsoft/knowledge/style/use-this-keyword-in-codeunits.good.al +++ /dev/null @@ -1,21 +0,0 @@ -codeunit 51116 "Style Sample ThisKeyword Good" -{ - procedure ProcessRecord(var Customer: Record Customer) - var - Other: Codeunit "Style Sample ThisKeyword Good"; - begin - // Clearly this codeunit's method. - this.ValidateCustomer(Customer); - - // Only way to pass the current codeunit as an argument. - Other.DoWith(this); - end; - - local procedure ValidateCustomer(var Customer: Record Customer) - begin - end; - - procedure DoWith(var Helper: Codeunit "Style Sample ThisKeyword Good") - begin - end; -} diff --git a/microsoft/knowledge/style/use-this-keyword-in-codeunits.md b/microsoft/knowledge/style/use-this-keyword-in-codeunits.md deleted file mode 100644 index 08a26f8..0000000 --- a/microsoft/knowledge/style/use-this-keyword-in-codeunits.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [this, codeunit, self-reference, aa0248, readability] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use the `this` keyword for codeunit self-reference - -## Description - -CodeCop rule AA0248 recommends the `this` keyword inside codeunit procedures when referring to the codeunit's own members or passing the codeunit itself to another procedure. AL's scope resolution otherwise blurs global-variable access, local-variable access, and same-codeunit method calls into the same unqualified syntax — a reader of `ValidateCustomer(Customer)` cannot tell at the call site whether `ValidateCustomer` is a local, a global, or a method on a different codeunit in scope. `this.ValidateCustomer(Customer)` removes the ambiguity, and `OtherCodeunit.DoWork(this)` is the only way to pass the current codeunit as a parameter. - -## Best Practice - -In codeunits, prefix same-codeunit method calls with `this.` when the call is ambiguous or when the scope spans more than a few lines. When the current codeunit needs to be passed as an argument, write `this` — there is no alternative syntax. The rule applies to codeunits; pages, reports, and tables have their own scoping. - -See sample: `use-this-keyword-in-codeunits.good.al`. - -## Anti Pattern - -`ValidateCustomer(Customer); SomeOtherCodeunit.DoWork(/* this codeunit? */);` — the first call has ambiguous origin, and the second cannot pass the current codeunit without `this`. The style becomes load-bearing as the codeunit grows past a few small procedures. - -See sample: `use-this-keyword-in-codeunits.bad.al`. diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al b/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al new file mode 100644 index 0000000..3131fa6 --- /dev/null +++ b/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al @@ -0,0 +1,13 @@ +codeunit 50247 "Sample Var Order Bad" +{ + procedure Run() + var + CustomerNo: Code[20]; + TempBuffer: Record "Integer" temporary; + Amount: Decimal; + Customer: Record Customer; + IsValid: Boolean; + begin + IsValid := Customer.Get(CustomerNo); + end; +} diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.good.al b/microsoft/knowledge/style/variable-declaration-order-by-type.good.al new file mode 100644 index 0000000..590ed25 --- /dev/null +++ b/microsoft/knowledge/style/variable-declaration-order-by-type.good.al @@ -0,0 +1,13 @@ +codeunit 50246 "Sample Var Order Good" +{ + procedure Run() + var + Customer: Record Customer; + TempBuffer: Record "Integer" temporary; + CustomerNo: Code[20]; + Amount: Decimal; + IsValid: Boolean; + begin + IsValid := Customer.Get(CustomerNo); + end; +} diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.md b/microsoft/knowledge/style/variable-declaration-order-by-type.md new file mode 100644 index 0000000..3435726 --- /dev/null +++ b/microsoft/knowledge/style/variable-declaration-order-by-type.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [variable-declaration, order, var, complex-types, aa0021] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Order variable declarations by type, complex types first (CodeCop AA0021) + +## Description + +CodeCop AA0021 requires that variable declarations inside a `var` block follow a fixed ordering by type, with complex (composite) types appearing before primitive types. The canonical order is `Record`, then `Report`, `Codeunit`, `XmlPort`, `Page`, `Query`, `Notification`, `BigText`, `DateFormula`, `RecordId`, `RecordRef`, `FieldRef`, `FilterPageBuilder`, then the simple types `Text`, `Code`, `Integer`, `Decimal`, `Boolean`, `Date`, `Time`, `DateTime`, `Char`, `Byte`. Inside each type group the variables can be alphabetical or in usage order. Temporary records still sort under `Record`. + +## Best Practice + +Declare all `Record` variables first, then other complex types, then primitives. A consistent order makes diffs review-friendly and matches the convention enforced by the AL formatter and CodeCop. + +See sample: `variable-declaration-order-by-type.good.al`. + +## Anti Pattern + +A `var` block where records and primitives are interleaved — `CustomerNo: Code[20];` between two `Record` variables, or `Amount: Decimal;` declared above the `Customer: Record Customer;` it is computed from. AA0021 flags it and the block is harder to scan; readers expect composite types at the top. + +See sample: `variable-declaration-order-by-type.bad.al`. diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al b/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al new file mode 100644 index 0000000..65c8223 --- /dev/null +++ b/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al @@ -0,0 +1,19 @@ +codeunit 50249 "Sample Shadow Bad" +{ + var + Customer: Record Customer; + + procedure ProcessSales() + var + Customer: Text; + Amount: Decimal; + begin + Customer := 'C-100'; + Amount := 0; + end; + + procedure Amount(): Decimal + begin + exit(0); + end; +} diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.good.al b/microsoft/knowledge/style/variable-name-must-not-shadow.good.al new file mode 100644 index 0000000..f5391ef --- /dev/null +++ b/microsoft/knowledge/style/variable-name-must-not-shadow.good.al @@ -0,0 +1,19 @@ +codeunit 50248 "Sample No Shadow Good" +{ + var + CustomerRec: Record Customer; + + procedure ProcessSales() + var + CustomerName: Text; + SalesAmount: Decimal; + begin + CustomerName := CustomerRec.Name; + SalesAmount := GetAmount(); + end; + + procedure GetAmount(): Decimal + begin + exit(0); + end; +} diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.md b/microsoft/knowledge/style/variable-name-must-not-shadow.md new file mode 100644 index 0000000..4fee2da --- /dev/null +++ b/microsoft/knowledge/style/variable-name-must-not-shadow.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [variable-name, shadow, conflict, aa0198, aa0202, aa0204, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Local variable names must not shadow globals, fields, methods, or actions (CodeCop AA0198/AA0202/AA0204) + +## Description + +Three CodeCop rules — AA0198, AA0202, AA0204 — together forbid a local variable from sharing a name with a global variable on the same object, with a field on the same table or page source, with a procedure on the same object, or with an action on the same page. The compiler resolves the conflict by binding the closer scope, so a local `Customer: Text` will silently override a global `Customer: Record Customer` for the duration of a procedure — every call site reading `Customer.Name` from inside that procedure refers to the text, and the breakage is invisible to a reader who has both declarations on screen. + +## Best Practice + +Differentiate every local declaration from globals, fields, procedures, and actions on the same object. `Customer` global plus `CustomerName` local; method `GetAmount` plus local `SalesAmount`. The standard pattern is to attach a noun suffix to the local (`CustomerName`, `CustomerRec`, `CustomerNo`) rather than to the global. + +See sample: `variable-name-must-not-shadow.good.al`. + +## Anti Pattern + +A procedure that declares a local `Customer: Text` inside a codeunit that already has a global `Customer: Record Customer`. The local wins and the global becomes unreachable inside the procedure. AA0198/AA0202/AA0204 flag this category of conflict whether the colliding entity is a global, a field, a method, or an action. + +See sample: `variable-name-must-not-shadow.bad.al`. diff --git a/microsoft/knowledge/style/xmldoc-for-public-library-procedures.md b/microsoft/knowledge/style/xmldoc-for-public-library-procedures.md new file mode 100644 index 0000000..bffa5e8 --- /dev/null +++ b/microsoft/knowledge/style/xmldoc-for-public-library-procedures.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: style +keywords: [xmldoc, summary, param, returns, public-procedure, documentation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Add XML documentation to public procedures on library/API codeunits + +## Description + +XML documentation comments (`/// `, `/// …`, `/// `) are expected on procedures that form the public surface of a library — codeunits intended to be called from outside the current app: System App modules, AppSource library codeunits, `Access = Public` codeunits exposed for extension. The supported tags are ``, ``, ``, ``, ``, and ``. Active wording is preferred — `'Sets…'`, `'Gets…'`, `'Specifies…'` — and the docs should list parameter preconditions and any exceptions the procedure may raise. + +XML docs are NOT required on internal procedures, event subscribers, trigger implementations, page-part procedures, test procedures, or the object declarations themselves (tables, pages, codeunits). The reviewer signal is a `procedure` (not `local procedure`, not `internal procedure`) declared inside a codeunit whose role is "library" — those need XML docs; everything else is optional. + +## Best Practice + +For every public procedure on a library codeunit, write a `` describing what the procedure does, one `` per parameter naming its role and preconditions, and `` describing the return when applicable. Avoid placeholder text — `Validates discount` is no better than no doc at all. + +## Anti Pattern + +A public procedure on a library codeunit with no XML doc, or a `` that restates the procedure name in three words. The first leaves consumers guessing at intent; the second wastes the slot a meaningful description should occupy. diff --git a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.bad.al b/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.bad.al deleted file mode 100644 index a519d8d..0000000 --- a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.bad.al +++ /dev/null @@ -1,26 +0,0 @@ -page 51005 "UI Sample ActionTooltip Bad" -{ - PageType = Card; - SourceTable = "Sales Header"; - - actions - { - area(Processing) - { - action(Post) - { - Caption = 'Post'; - ApplicationArea = All; - // Declarative, not imperative. No period. - ToolTip = 'This will post the invoice'; - } - action(SendForApproval) - { - Caption = 'Send for approval'; - ApplicationArea = All; - // Fragment that repeats the caption and says nothing new. - ToolTip = 'Send for approval'; - } - } - } -} diff --git a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.good.al b/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.good.al deleted file mode 100644 index 2dcab7e..0000000 --- a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.good.al +++ /dev/null @@ -1,25 +0,0 @@ -page 51004 "UI Sample ActionTooltip Good" -{ - PageType = Card; - SourceTable = "Sales Header"; - - actions - { - area(Processing) - { - action(Post) - { - Caption = 'Post'; - ApplicationArea = All; - // Imperative verb-first sentence, Sentence case, terminating period. - ToolTip = 'Post the current sales invoice and finalize the transaction.'; - } - action(SendForApproval) - { - Caption = 'Send for approval'; - ApplicationArea = All; - ToolTip = 'Send the document to the approval workflow.'; - } - } - } -} diff --git a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.md b/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.md deleted file mode 100644 index bc2d457..0000000 --- a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [tooltip, action, imperative, voice, period] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Action tooltips are imperative, verb-first sentences ending with a period - -## Description - -Action tooltips describe what the user will cause by invoking the action. The house style is an imperative verb-first sentence — `Post the current sales invoice and finalize the transaction.` — not a declarative one ("This will post …") and not a fragment ("Post invoice"). The imperative voice matches how the user reads the action bar: each tooltip completes the sentence "If I click this, the system will …" in the same grammatical form. Shortcut-key hints, when present, belong at the end of the tooltip and are retained verbatim. - -## Best Practice - -Start the tooltip with the verb. Use Sentence case, end with a period, stay within the ~250-character budget. Keep one sentence unless the action genuinely needs two; avoid editorializing ("Easily post …") or narrating ("This action posts …"). Preserve any existing shortcut annotation. - -See sample: `action-tooltips-are-imperative-and-end-with-period.good.al`. - -## Anti Pattern - -`ToolTip = 'This will post the invoice'` — declarative rather than imperative, no period. `ToolTip = 'Post'` — one-word fragment that duplicates the Caption and says nothing new. Both fail the scan-the-action-bar comprehension test. - -See sample: `action-tooltips-are-imperative-and-end-with-period.bad.al`. diff --git a/microsoft/knowledge/ui/avoid-banned-ui-terms.md b/microsoft/knowledge/ui/avoid-banned-ui-terms.md deleted file mode 100644 index efb5c79..0000000 --- a/microsoft/knowledge/ui/avoid-banned-ui-terms.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [terminology, disabled, invalid, whitelist, blacklist, voice] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Avoid banned UI terms; prefer the inclusive and direct replacements - -## Description - -Business Central's UI voice guidelines exclude four terms that carry connotations the product does not want to push onto users: "Disabled" (clinical/negative), "Invalid" (pejorative), "Whitelist" and "Blacklist" (terms with racial associations the industry has moved away from). The replacements read naturally, match the product's warm-and-direct voice, and align with Microsoft's cross-product terminology. The concern applies to user-visible text — captions, tooltips, error messages, notifications — not to variable names or code comments. - -## Best Practice - -Replace "Disabled" with "Turned off" or "Not available". Replace "Invalid" with "Not valid" or "Incorrect". Replace "Whitelist" with "Allow list". Replace "Blacklist" with "Block list". Apply the substitution in all UI text surfaces: Caption, ToolTip, AboutTitle, AboutText, Label values, Message/Confirm/Error strings. - -## Anti Pattern - -`ErrorLbl: Label 'Invalid input.'`, `Caption = 'Disabled Users'`, `ToolTip = 'Specifies the blacklist of blocked senders.'` — all three terms in places the user will read. The fix is literal substitution with the approved alternative. diff --git a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.bad.al b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.bad.al deleted file mode 100644 index 8c82cf1..0000000 --- a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.bad.al +++ /dev/null @@ -1,21 +0,0 @@ -page 51001 "UI Sample Caption Bad" -{ - PageType = List; - SourceTable = Customer; - - // Noun phrase in Sentence case. Every other list page in the product is Title Case. - Caption = 'Sales orders'; - - actions - { - area(Processing) - { - // Sentence phrase in Title Case. Reads as a typo. - action(PostAndPrint) - { - Caption = 'Post And Print'; - ApplicationArea = All; - } - } - } -} diff --git a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.good.al b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.good.al deleted file mode 100644 index e3bec15..0000000 --- a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.good.al +++ /dev/null @@ -1,27 +0,0 @@ -page 51000 "UI Sample Caption Good" -{ - PageType = List; - SourceTable = Customer; - - // Noun-phrase page caption: Title Case. - Caption = 'Sales Orders'; - - actions - { - area(Processing) - { - // Sentence-phrase action caption: Sentence case. - action(PostAndPrint) - { - Caption = 'Post and print'; - ApplicationArea = All; - } - - action(SendEmail) - { - Caption = 'Send email'; - ApplicationArea = All; - } - } - } -} diff --git a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md deleted file mode 100644 index 595010f..0000000 --- a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [caption, capitalization, title-case, sentence-case, noun-phrase] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Capitalize captions by phrase type: noun phrase is Title Case, sentence phrase is Sentence case - -## Description - -Business Central UI captions follow a simple capitalization rule that depends on the grammatical shape of the caption, not its location. A caption that is a pure noun phrase — no verb — uses Title Case: each major word capitalized (`Sales Orders`, `Chart of Accounts`, `Payment Terms`). A caption that is an imperative or declarative sentence phrase — contains a verb — uses Sentence case: only the first word and proper nouns capitalized (`Post and print`, `Send email`, `Create flow`). Following the rule makes unrelated captions feel consistent; ignoring it is visibly inconsistent in the user's navigation. - -## Best Practice - -Decide by parsing the caption as a phrase. "Sales Orders" is a thing; Title Case. "Post and print" tells the user to do something; Sentence case. For captions that are literally a single noun (`Save`, `Close`), treat them as sentence phrases — the imperative verb is implied. - -See sample: `caption-capitalization-noun-phrase-vs-sentence-phrase.good.al`. - -## Anti Pattern - -Writing `Caption = 'Sales orders'` on a list page (noun phrase styled as a sentence) or `Caption = 'Post And Print'` on an action (sentence phrase styled as title case). Both read as typos to a native English reader and inconsistent to a translator. - -See sample: `caption-capitalization-noun-phrase-vs-sentence-phrase.bad.al`. diff --git a/microsoft/knowledge/ui/control-add-in-accessibility-is-developer-responsibility.md b/microsoft/knowledge/ui/control-add-in-accessibility-is-developer-responsibility.md new file mode 100644 index 0000000..cc74054 --- /dev/null +++ b/microsoft/knowledge/ui/control-add-in-accessibility-is-developer-responsibility.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [control-add-in, javascript, accessibility, framework, wcag] +technologies: [al, javascript] +countries: [w1] +application-area: [all] +--- + +# Control add-in accessibility is the developer's responsibility + +## Description + +When a developer builds a JavaScript control add-in, they bypass the Business Central framework's built-in accessibility support and take full responsibility for the accessibility of the rendered HTML, JavaScript, and CSS. Unlike standard AL page controls, an add-in receives no automatic ARIA semantics, no automatic keyboard handling, and no automatic high-contrast support from the BC client. + +Control add-in code must be reviewed for WCAG 2.1 AA compliance and general accessibility best practices. Automated review is inherently non-exhaustive — many accessibility issues (keyboard flow, screen reader announcements, dynamic behavior) require manual testing. + +## Best Practice + +Treat every UI-rendering change to a control add-in as something the platform will not catch for you: accessible names, semantic HTML, keyboard reachability, focus management, contrast, and reflow are all yours to verify. When reporting issues in control add-in code, include a recommendation that a manual accessibility review accompany any control add-in that renders a UI. diff --git a/microsoft/knowledge/ui/control-add-in-has-no-bc-color-tokens.md b/microsoft/knowledge/ui/control-add-in-has-no-bc-color-tokens.md new file mode 100644 index 0000000..ac396e2 --- /dev/null +++ b/microsoft/knowledge/ui/control-add-in-has-no-bc-color-tokens.md @@ -0,0 +1,18 @@ +--- +bc-version: [all] +domain: ui +keywords: [control-add-in, color-tokens, theming, high-contrast, forced-colors, accessibility] +technologies: [al, javascript] +countries: [w1] +application-area: [all] +--- + +# Control add-ins cannot use BC color tokens or theming + +## Description + +A JavaScript control add-in has no access to Business Central's color tokens or theming system. The BC client will not push theme variables, accent colors, or high-contrast palettes into the add-in's iframe. As a result, the add-in must handle Windows contrast themes independently — for example by responding to the `forced-colors` CSS media query or an equivalent mechanism, and by ensuring its own contrast ratios meet WCAG AA (4.5:1 for normal text, 3:1 for large text and UI components) against the backgrounds it draws. + +## Best Practice + +Style control add-ins with explicit colors that are known to meet contrast requirements, and add a `forced-colors` (or equivalent) branch so that Windows high-contrast users see a usable rendering. Do not assume that the add-in inherits BC's theme — verify the rendered output in default, dark, and high-contrast themes. diff --git a/microsoft/knowledge/ui/cosmetic-styles-need-no-textual-context.md b/microsoft/knowledge/ui/cosmetic-styles-need-no-textual-context.md new file mode 100644 index 0000000..02d2f13 --- /dev/null +++ b/microsoft/knowledge/ui/cosmetic-styles-need-no-textual-context.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: ui +keywords: [style, cosmetic, attention, strong, subordinate, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Cosmetic styles need no textual context + +## Description + +A field's `Style` property controls text formatting. Some style values are purely **cosmetic** — they change visual appearance but do not convey semantic meaning. Cosmetic styles never require additional context and must not be reported as accessibility findings: + +- `None`, `Standard` +- `StandardAccent` (Blue) +- `Strong` (Bold), `StrongAccent` (Blue + Bold) +- `Attention` (Red + Italic), `AttentionAccent` (Blue + Italic) +- `Subordinate` (Grey) + +This list is exhaustive — every other named style on the platform either falls outside the cosmetic set or is one of the three semantic styles documented in `semantic-styles-need-independent-textual-meaning.md`. + +The same rule applies whether the cosmetic style is set via `Style` directly or via a `StyleExpr` Text variable. If the resolved value at runtime is one of the cosmetic styles above, the field is safe. + +## Best Practice + +Use cosmetic styles freely for visual emphasis. Do not treat the use of `Attention`, `Strong`, or any other cosmetic value as an accessibility issue — the colors and weights are purely presentational and carry no meaning a screen reader needs to convey. diff --git a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md b/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md deleted file mode 100644 index e00842c..0000000 --- a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [tooltip, field, specifies, voice, period] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Field tooltips start with "Specifies" and end with a period - -## Description - -Field tooltips describe what a value means, and the Business Central house style for them is a declarative sentence that starts with "Specifies" and ends with a period. The convention is not cosmetic: it yields a consistent voice across thousands of fields so a user scanning several tooltips in quick succession can compare them without re-parsing each opening clause. Alternative phrasings ("Shows …", "The …") are accepted when they describe the field clearly, but "Specifies …" is the default and the easiest to translate consistently. - -## Best Practice - -Write field tooltips as `Specifies .` — a single sentence, Sentence case, terminating period. Keep under the ~250-character tooltip budget (see `respect-ui-text-character-limits`). When the field's meaning is genuinely not a "specifies" sentence, use "Shows …" or a clearly descriptive alternative; avoid bare fragments. - -See sample: `field-tooltips-start-with-specifies-and-end-with-period.good.al`. - -## Anti Pattern - -`ToolTip = 'The name of the customer'` — missing "Specifies" opener, missing period. `ToolTip = 'Customer name'` — a fragment rather than a sentence. Both sit inconsistently next to adjacent "Specifies …" tooltips on the same page. - -See sample: `field-tooltips-start-with-specifies-and-end-with-period.bad.al`. diff --git a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.good.al b/microsoft/knowledge/ui/grid-data-table-heuristic.good.al similarity index 56% rename from microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.good.al rename to microsoft/knowledge/ui/grid-data-table-heuristic.good.al index c290629..21a3851 100644 --- a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.good.al +++ b/microsoft/knowledge/ui/grid-data-table-heuristic.good.al @@ -1,25 +1,30 @@ -page 50732 "UI Grid Good" +page 50207 "UI Sample Data Table" { + PageType = Card; + SourceTable = Customer; + layout { area(Content) { - grid(BalanceGrid) + grid(DataGrid) { GridLayout = Columns; - group(CustomerColumn) + group(Column1) { ShowCaption = false; - field(CustomerName; Rec."Customer Name") + field(Name; Rec.Name) { + ApplicationArea = All; ShowCaption = false; } } - group(BalanceColumn) + group(Column2) { ShowCaption = false; - field(Balance; Rec.Balance) + field(Balance; Rec."Balance (LCY)") { + ApplicationArea = All; ShowCaption = false; } } diff --git a/microsoft/knowledge/ui/grid-data-table-heuristic.md b/microsoft/knowledge/ui/grid-data-table-heuristic.md new file mode 100644 index 0000000..2cd6b63 --- /dev/null +++ b/microsoft/knowledge/ui/grid-data-table-heuristic.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, fixed, data-table, heuristic, show-caption, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Grid and fixed-layout data-table heuristic + +## Description + +Business Central renders `grid()` and `fixed()` layouts in two modes. The mode is chosen automatically by a client heuristic. A grid renders as a **data table** (HTML `` with row/column semantics) only when **all** of the following are true: + +- All direct children of the grid/fixed are groups (no loose fields). +- Every child of every group is a field (no nested groups or other controls). +- All fields have `ShowCaption = false`. + +The heuristic checks field captions only — group `ShowCaption` is not part of the check. A group with a visible caption inside a data-table grid does **not** break the heuristic and is not a violation. However, groups in a data table should also have `ShowCaption = false` for correct visual presentation. + +Any grid or fixed layout that does not meet all three conditions renders as a layout table (visual column arrangement, no table semantics). + +## Best Practice + +If you intend a grid or fixed layout to render as a data table, satisfy all three conditions and verify the resulting markup matches your intent. If you do not need tabular semantics, prefer simple groups over grid or fixed layouts — they reflow better and produce correct semantic markup automatically. + +See sample: `grid-data-table-heuristic.good.al`. diff --git a/microsoft/knowledge/ui/group-caption-quality-is-not-an-accessibility-issue.md b/microsoft/knowledge/ui/group-caption-quality-is-not-an-accessibility-issue.md new file mode 100644 index 0000000..7c546b8 --- /dev/null +++ b/microsoft/knowledge/ui/group-caption-quality-is-not-an-accessibility-issue.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [group, caption, missing, duplicate, generic, accessibility, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Group caption quality is not an accessibility issue + +## Description + +Group captions affect page organization, but missing, generic, or duplicate group captions are **not** accessibility violations per the BC accessibility rules. Do not flag groups for missing, generic, or duplicate captions during an accessibility review. + +This rule prevents a common false positive: LLM-driven reviewers tend to flag "GroupName" or duplicated `Caption = 'General'` as accessibility issues, but the BC client does not depend on group captions for screen-reader announcements of the fields within. Caption quality belongs to other review domains (UI text / style), not accessibility. + +## Best Practice + +Treat group caption quality as a UI-text concern reviewed elsewhere. Accessibility findings on groups should be limited to the specific patterns documented in the `grid-data-table-heuristic.md`, `tabular-intent-requires-data-table-conditions.md`, and `group-labeled-first-child-exception.md` files. diff --git a/microsoft/knowledge/ui/group-labeled-first-child-exception.bad.al b/microsoft/knowledge/ui/group-labeled-first-child-exception.bad.al new file mode 100644 index 0000000..648e729 --- /dev/null +++ b/microsoft/knowledge/ui/group-labeled-first-child-exception.bad.al @@ -0,0 +1,22 @@ +page 50204 "UI Sample First Child Bad" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + group(SomeGroup) + { + ShowCaption = false; + field(DescriptionField; Rec.Address) + { + ApplicationArea = All; + ShowCaption = false; + MultiLine = true; + } + } + } + } +} diff --git a/microsoft/knowledge/ui/keep-captions-on-editable-fields.good.al b/microsoft/knowledge/ui/group-labeled-first-child-exception.good.al similarity index 60% rename from microsoft/knowledge/ui/keep-captions-on-editable-fields.good.al rename to microsoft/knowledge/ui/group-labeled-first-child-exception.good.al index adb3ccd..3852237 100644 --- a/microsoft/knowledge/ui/keep-captions-on-editable-fields.good.al +++ b/microsoft/knowledge/ui/group-labeled-first-child-exception.good.al @@ -1,5 +1,8 @@ -page 50730 "UI Caption Good" +page 50203 "UI Sample First Child Good" { + PageType = Card; + SourceTable = Customer; + layout { area(Content) @@ -7,15 +10,13 @@ page 50730 "UI Caption Good" group(Description) { Caption = 'Description'; - field(DescriptionField; Rec.Description) + field(DescriptionField; Rec.Address) { - MultiLine = true; + ApplicationArea = All; ShowCaption = false; + MultiLine = true; } } - field(CustomerName; Rec."Customer Name") - { - } } } } diff --git a/microsoft/knowledge/ui/group-labeled-first-child-exception.md b/microsoft/knowledge/ui/group-labeled-first-child-exception.md new file mode 100644 index 0000000..2f47ae3 --- /dev/null +++ b/microsoft/knowledge/ui/group-labeled-first-child-exception.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, group, first-child, multiline, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Group-labeled first child exception + +## Description + +`ShowCaption = false` is acceptable on an editable field only when **all** of the following conditions are met: + +1. The control is the **first visible field** in its parent group. +2. The field has `ShowCaption = false`. +3. The parent group has a visible caption: `ShowCaption` is true (the default) **and** the group has a non-empty `Caption` value. + +When these three conditions hold, the group caption becomes the accessible label for the field. This works regardless of whether the field is multiline. The presence of `InstructionalText` on the field is irrelevant to this check. + +## Best Practice + +Do not second-guess this exception. If the three conditions are met, the pattern is acceptable — even if the group caption seems generic (e.g. "General Information") or does not exactly match the field name. + +See sample: `group-labeled-first-child-exception.good.al`. + +## Anti Pattern + +If the parent group has `ShowCaption = false` or no `Caption`, the first-child exception does not apply: the field has no accessible label anywhere. + +See sample: `group-labeled-first-child-exception.bad.al`. diff --git a/microsoft/knowledge/ui/group-show-caption-false-outside-grid-is-not-a-violation.md b/microsoft/knowledge/ui/group-show-caption-false-outside-grid-is-not-a-violation.md new file mode 100644 index 0000000..fba4bfd --- /dev/null +++ b/microsoft/knowledge/ui/group-show-caption-false-outside-grid-is-not-a-violation.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [group, show-caption, card, document, layout, accessibility, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Group ShowCaption = false outside grid/fixed is a layout choice + +## Description + +In a standard Card or Document page, a group with `ShowCaption = false` is a layout choice, not an accessibility violation. Only flag `ShowCaption` issues as documented in the grid/fixed-layout and field-level `ShowCaption` rules — `show-caption-on-editable-fields.md`, `grid-data-table-heuristic.md`, `tabular-intent-requires-data-table-conditions.md`. + +The heuristic in BC's client uses **field** captions to decide between data-table and layout-table rendering. A captionless group (outside a grid or fixed layout) does not strip labels from its child fields — each field retains its own caption. + +## Best Practice + +Reserve accessibility findings for hidden **field** labels and grid-semantics problems. Do not raise a finding merely because a `group` block has `ShowCaption = false` in an ordinary Card or Document page layout. diff --git a/microsoft/knowledge/ui/keep-captions-on-editable-fields.bad.al b/microsoft/knowledge/ui/keep-captions-on-editable-fields.bad.al deleted file mode 100644 index 0d206a1..0000000 --- a/microsoft/knowledge/ui/keep-captions-on-editable-fields.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -page 50731 "UI Caption Bad" -{ - layout - { - area(Content) - { - field(CustomerName; Rec."Customer Name") - { - InstructionalText = 'Enter the customer name.'; - ShowCaption = false; - } - } - } -} diff --git a/microsoft/knowledge/ui/keep-captions-on-editable-fields.md b/microsoft/knowledge/ui/keep-captions-on-editable-fields.md deleted file mode 100644 index aeea903..0000000 --- a/microsoft/knowledge/ui/keep-captions-on-editable-fields.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [showcaption, editable, accessibility, screen-reader, label] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Keep captions on editable fields - -## Description - -`ShowCaption = false` on an editable page field removes the visible and accessible label that identifies the input. `InstructionalText` is not a replacement: it behaves like placeholder text, disappears after entry, and is not reliably announced as the field label. The default `ShowCaption = true` is the safe form-field pattern. - -## Best Practice - -Leave captions visible on editable fields. `ShowCaption = false` is acceptable for non-editable content fields, for fields inside a valid data-table grid pattern, and for the first visible field in a parent group with a visible non-empty caption; in that last pattern, the group caption becomes the accessible label. - -See sample: `keep-captions-on-editable-fields.good.al`. - -## Anti Pattern - -Hiding the caption on an editable field because the page layout looks cleaner, or because `InstructionalText` appears to describe the input. Screen reader users lose the field label, and sighted users lose the persistent visual cue. - -See sample: `keep-captions-on-editable-fields.bad.al`. diff --git a/microsoft/knowledge/ui/layout-table-with-captions-is-valid.md b/microsoft/knowledge/ui/layout-table-with-captions-is-valid.md new file mode 100644 index 0000000..53caaec --- /dev/null +++ b/microsoft/knowledge/ui/layout-table-with-captions-is-valid.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, fixed, layout-table, show-caption, false-positive, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Layout-table grids with visible captions are valid + +## Description + +A grid or fixed layout that does not meet all three data-table conditions renders as a **layout table**. A layout table where editable fields keep their visible captions is not an accessibility violation. Each field is labeled by its own caption — this is a valid, accessible pattern. + +Do not flag a grid or fixed layout as an accessibility issue merely because it does not meet the data-table heuristic. The violation is hidden labels in a non-data-table grid, not the layout choice itself. + +## Best Practice + +When reviewing a grid or fixed layout, first check whether it meets all data-table conditions. If yes, `ShowCaption = false` on fields is correct. If no, allow editable fields to keep their captions and only flag the cases enumerated in `tabular-intent-requires-data-table-conditions.md`. diff --git a/microsoft/knowledge/ui/manually-review-control-addin-ui-accessibility.md b/microsoft/knowledge/ui/manually-review-control-addin-ui-accessibility.md deleted file mode 100644 index 03123aa..0000000 --- a/microsoft/knowledge/ui/manually-review-control-addin-ui-accessibility.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [control-addin, javascript, accessibility, wcag, keyboard, aria] -technologies: [al, javascript] -countries: [w1] -application-area: [all] ---- - -# Manually review UI-rendering control add-in changes for accessibility - -## Description - -JavaScript control add-ins bypass much of the Business Central client's built-in accessibility support. Once the add-in renders its own HTML, JavaScript, or CSS, the extension owns WCAG 2.1 AA concerns such as accessible names, semantic HTML, keyboard navigation, color contrast, focus management, and 200% zoom/reflow. Automated review cannot exhaustively verify those behaviours. - -## Best Practice - -When a control add-in change touches DOM creation, templates, CSS, interaction handlers, ARIA attributes, dynamic visibility, or focus flow, include a manual accessibility review finding even if no specific defect is obvious. Do not require manual accessibility review for pure data processing or API changes that do not render UI. - -## Anti Pattern - -Treating a control add-in diff as clean because no AL page properties changed. A new `div`-based button without an accessible name, a keyboard trap, or a color-only status indicator lives in JavaScript and still affects Business Central users. diff --git a/microsoft/knowledge/ui/no-nested-grids.bad.al b/microsoft/knowledge/ui/no-nested-grids.bad.al new file mode 100644 index 0000000..03c4135 --- /dev/null +++ b/microsoft/knowledge/ui/no-nested-grids.bad.al @@ -0,0 +1,33 @@ +page 50210 "UI Sample Nested Grid Bad" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + grid(OuterGrid) + { + GridLayout = Columns; + group(Left) + { + ShowCaption = false; + grid(InnerGrid) + { + GridLayout = Rows; + group(Row1) + { + ShowCaption = false; + field(Name; Rec.Name) + { + ApplicationArea = All; + ShowCaption = false; + } + } + } + } + } + } + } +} diff --git a/microsoft/knowledge/ui/no-nested-grids.md b/microsoft/knowledge/ui/no-nested-grids.md new file mode 100644 index 0000000..9de7ba4 --- /dev/null +++ b/microsoft/knowledge/ui/no-nested-grids.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, nested-grid, fixed, data-table, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Nested grids are not supported + +## Description + +A grid nested inside another grid is not a supported pattern in Business Central. Even if an inner grid independently meets the data-table heuristic, the outer grid fails because its groups contain non-field children (the inner grids). The result is broken table semantics for both layers. + +Always flag a nested grid as a violation. The fix is to restructure the page so there is at most one grid in any branch of the layout tree, choosing either a data-table or a layout-table arrangement. + +## Anti Pattern + +Wrapping a working data-table grid inside another grid in an attempt to compose two tabular regions side by side. The outer grid silently degrades to layout-table rendering, the inner grid's headers are no longer associated with the outer structure, and editable fields with `ShowCaption = false` lose their labels. + +See sample: `no-nested-grids.bad.al`. diff --git a/microsoft/knowledge/ui/on-drill-down-on-non-editable-fields-renders-as-link.md b/microsoft/knowledge/ui/on-drill-down-on-non-editable-fields-renders-as-link.md new file mode 100644 index 0000000..c27503d --- /dev/null +++ b/microsoft/knowledge/ui/on-drill-down-on-non-editable-fields-renders-as-link.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [on-drill-down, link, non-editable, accessibility, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# OnDrillDown on non-editable fields renders as a link + +## Description + +The Business Central client renders non-editable fields that have an `OnDrillDown` trigger as HTML `` (anchor) elements. Screen readers correctly announce these as links. `OnDrillDown` on a non-editable field is therefore **not** an accessibility issue — the platform handles the semantics. + +Do not flag `OnDrillDown` usage as an accessibility issue. The combination of `Editable = false` and `OnDrillDown` is the standard BC pattern for navigable, screen-reader-friendly value cells in list and card pages. + +## Best Practice + +Use `OnDrillDown` freely on non-editable fields when you want users to navigate from a value to a related record or detail page. No additional ARIA attributes or accessible-name workarounds are required. diff --git a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.bad.al b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.bad.al deleted file mode 100644 index 5af36ca..0000000 --- a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.bad.al +++ /dev/null @@ -1,19 +0,0 @@ -page 50735 "UI Style Bad" -{ - layout - { - area(Content) - { - field(Score; Score) - { - Caption = 'Score'; - Style = Favorable; - StyleExpr = IsGood; - } - } - } - - var - Score: Integer; - IsGood: Boolean; -} diff --git a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.good.al b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.good.al deleted file mode 100644 index 496d892..0000000 --- a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.good.al +++ /dev/null @@ -1,19 +0,0 @@ -page 50734 "UI Style Good" -{ - layout - { - area(Content) - { - field(ValidationStatus; ValidationStatus) - { - Caption = 'Validation status'; - Style = Unfavorable; - StyleExpr = HasValidationErrors; - } - } - } - - var - ValidationStatus: Text; - HasValidationErrors: Boolean; -} diff --git a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.md b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.md deleted file mode 100644 index f266288..0000000 --- a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [style, styleexpr, favorable, unfavorable, ambiguous, accessibility] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Provide text meaning for semantic styles - -## Description - -Most Business Central page styles are cosmetic, but `Favorable`, `Unfavorable`, and `Ambiguous` communicate meaning through color. Color-only meaning is not accessible. A user who cannot perceive the style must still be able to determine whether the value is positive, negative, or uncertain from the caption, value, or nearby text. - -## Best Practice - -Use semantic styles only when the meaning is independently available: a caption such as "Error", a value such as "Failed", a signed number whose sign carries the meaning, or an adjacent status field. Cosmetic styles such as `Strong`, `Attention`, and `Subordinate` do not need this extra check. Cue tiles inside `cuegroup` are exempt because the client supplies accessible semantic labels. - -See sample: `provide-text-meaning-for-semantic-styles.good.al`. - -## Anti Pattern - -Applying `Style = Favorable`, `Unfavorable`, or `Ambiguous` to a value whose text is neutral, such as "42" or "Open", without any caption or adjacent field explaining what the color means. - -See sample: `provide-text-meaning-for-semantic-styles.bad.al`. diff --git a/microsoft/knowledge/ui/respect-ui-text-character-limits.md b/microsoft/knowledge/ui/respect-ui-text-character-limits.md deleted file mode 100644 index 7577656..0000000 --- a/microsoft/knowledge/ui/respect-ui-text-character-limits.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [caption, tooltip, character-limit, truncation, localization] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Respect Business Central's UI text character limits to avoid truncation - -## Description - -Business Central UI surfaces have practical character limits before the platform truncates or the translator's localization overflows the available space. Authoring captions and tooltips close to the English limit almost guarantees truncation in languages whose translations are longer (German, French, Spanish average 20–40% longer than English). The limits are not hard compiler errors — they are product-quality thresholds that agents should flag at author time so the string reaches localization with room to grow. - -## Best Practice - -Author within these approximate limits (English): action and field captions ~40 chars; field-group, menu-item, page, and dialog titles ~40 chars; button captions ~20 chars; action and field tooltips ~250 chars; dialog text and error messages ~250 chars; notifications ~100 chars; checklist ShortTitleChecklist 34, LongerTitleCard 53, CardDescription 180. Leave headroom for longer translations; at 40/40 in English, German is likely to truncate. - -## Anti Pattern - -`action(RecalculateAndReapplyAllOutstandingCustomerDiscounts) { Caption = 'Recalculate and reapply all outstanding customer discounts'; }` — 58 characters in English, essentially guaranteed to truncate once translated. The fix is to shorten the English caption (`Recalculate customer discounts`, 30 chars) and move the full sentence into the tooltip where the budget is larger. diff --git a/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.good.al b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.good.al new file mode 100644 index 0000000..5471632 --- /dev/null +++ b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.good.al @@ -0,0 +1,31 @@ +page 50213 "UI Sample CueGroup Style" +{ + PageType = RoleCenter; + + layout + { + area(RoleCenter) + { + cuegroup(Activities) + { + Caption = 'Activities'; + field(OverdueInvoices; OverdueInvoiceCount) + { + ApplicationArea = All; + Caption = 'Overdue Invoices'; + Style = Unfavorable; + } + field(PaidInvoices; PaidInvoiceCount) + { + ApplicationArea = All; + Caption = 'Paid Invoices'; + Style = Favorable; + } + } + } + } + + var + OverdueInvoiceCount: Integer; + PaidInvoiceCount: Integer; +} diff --git a/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md new file mode 100644 index 0000000..5f26333 --- /dev/null +++ b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [style, cuegroup, cue-tile, favorable, unfavorable, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Semantic styles in a cuegroup are auto-labeled + +## Description + +Fields inside a `cuegroup` render as cue tiles. The Business Central client automatically provides an accessible label for semantic styles on cue tiles (for example, "Favorable", "Unfavorable"). Semantic styles in a `cuegroup` therefore do **not** need additional context and should be ignored when checking that semantic colors are backed by text. + +This is a narrow platform exception to `semantic-styles-need-independent-textual-meaning.md`. Outside a `cuegroup`, the normal rule applies. + +## Best Practice + +You may apply `Favorable`, `Unfavorable`, or `Ambiguous` to fields inside a `cuegroup` without supplying a redundant textual indicator — the platform supplies the screen-reader text. Reserve this shortcut for cue tiles only; do not extend it to other layout containers. + +See sample: `semantic-style-in-cuegroup-exception.good.al`. diff --git a/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.bad.al b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.bad.al new file mode 100644 index 0000000..4741a7c --- /dev/null +++ b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.bad.al @@ -0,0 +1,27 @@ +page 50212 "UI Sample Semantic Style Bad" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + field(CompanyName; Rec.Name) + { + ApplicationArea = All; + Style = Favorable; + } + field(Confidence; ConfidencePercent) + { + ApplicationArea = All; + Caption = 'Confidence'; + StyleExpr = ConfidenceStyle; + } + } + } + + var + ConfidencePercent: Decimal; + ConfidenceStyle: Text; +} diff --git a/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.good.al b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.good.al new file mode 100644 index 0000000..a42cc08 --- /dev/null +++ b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.good.al @@ -0,0 +1,28 @@ +page 50211 "UI Sample Semantic Style Good" +{ + PageType = Card; + SourceTable = "Cust. Ledger Entry"; + + layout + { + area(Content) + { + field(OverdueAmount; Rec."Remaining Amount") + { + ApplicationArea = All; + Caption = 'Overdue Amount'; + Style = Unfavorable; + } + field(ProfitMargin; Rec.Amount) + { + ApplicationArea = All; + Caption = 'Profit Margin'; + Style = Favorable; + StyleExpr = IsProfitable; + } + } + } + + var + IsProfitable: Boolean; +} diff --git a/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md new file mode 100644 index 0000000..9d58d41 --- /dev/null +++ b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md @@ -0,0 +1,34 @@ +--- +bc-version: [all] +domain: ui +keywords: [style, favorable, unfavorable, ambiguous, color, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Semantic styles need independent textual meaning + +## Description + +Three `Style` values carry semantic meaning through color and must be backed by text that conveys the same meaning: + +- `Favorable` (Bold + Green) — implies a positive outcome. +- `Unfavorable` (Bold + Italic + Red) — implies a negative outcome. +- `Ambiguous` (Yellow) — implies an uncertain or mixed outcome. + +For accessibility, assume the style is completely invisible to the user. The semantic meaning must be independently determinable from at least one of: + +1. The **field caption** matches the semantic meaning (e.g. caption "Error" with `Style = Unfavorable`, or "Profit" with `Style = Favorable`). +2. The **field value** communicates the meaning (e.g. value "Success!" with Favorable, a negative number with Unfavorable). +3. An **adjacent field** provides a textual representation of the semantic meaning (e.g. a "Status" column reads "High" / "Medium" / "Low" alongside a percentage field). + +The rule applies equally whether `Style` is set to a literal value or to a variable that evaluates to a semantic style at runtime. + +## Best Practice + +When you reach for `Favorable`, `Unfavorable`, or `Ambiguous`, verify that the caption, value, or an adjacent column already conveys the same meaning. See sample: `semantic-styles-need-independent-textual-meaning.good.al`. + +## Anti Pattern + +Applying a semantic style for purely cosmetic emphasis (e.g. green company name for aesthetics), or using semantic colors where only the color reveals the threshold (e.g. confidence percentages with no qualitative label). See sample: `semantic-styles-need-independent-textual-meaning.bad.al`. diff --git a/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.good.al b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.good.al new file mode 100644 index 0000000..16b5300 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.good.al @@ -0,0 +1,18 @@ +page 50202 "UI Sample NonEditable Caption" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + field("Customer Name"; Rec.Name) + { + ApplicationArea = All; + Editable = false; + ShowCaption = false; + } + } + } +} diff --git a/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md new file mode 100644 index 0000000..dcf4d95 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, non-editable, content, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ShowCaption = false on non-editable fields + +## Description + +When a field is explicitly non-editable (`Editable = false`), it serves as content rather than as a form field. In that case, `ShowCaption = false` is acceptable: there is no input control whose label could be lost. The combination signals to a reviewer (and to the platform) that the field displays a value standalone — for example a status message or a description that is meaningful on its own. + +This exception does **not** extend to dynamically editable fields. A field with `Editable = SomeBooleanExpression` may be editable at runtime and must keep its caption. + +## Best Practice + +If you want to hide a field's caption, pair `ShowCaption = false` with a literal `Editable = false`. Use this pattern only for content fields that do not act as labels for other fields in the same layout container. + +See sample: `show-caption-false-allowed-on-non-editable-fields.good.al`. diff --git a/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.good.al b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.good.al new file mode 100644 index 0000000..082ed47 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.good.al @@ -0,0 +1,31 @@ +page 50206 "UI Sample PromptDialog" +{ + PageType = PromptDialog; + Caption = 'Draft new project with Copilot'; + + layout + { + area(Prompt) + { + field(ProjectDescription; InputProjectDescription) + { + ApplicationArea = All; + ShowCaption = false; + MultiLine = true; + InstructionalText = 'Describe the project'; + } + } + area(Content) + { + field("Job Description"; JobDescription) + { + ApplicationArea = All; + Caption = 'Project Description'; + } + } + } + + var + InputProjectDescription: Text; + JobDescription: Text; +} diff --git a/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md new file mode 100644 index 0000000..9b2e43b --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, promptdialog, copilot, prompt, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ShowCaption in a PromptDialog prompt area + +## Description + +On `PageType = PromptDialog` pages, input fields inside `area(Prompt)` are labeled by the dialog's heading — the page `Caption`. Setting `ShowCaption = false` on such an input field is the standard pattern and should not be flagged, provided the page has a `Caption`. + +Fields in the `area(Content)` section of the same PromptDialog page are **not** labeled by the dialog heading and follow the normal `ShowCaption` rules. + +## Best Practice + +In a PromptDialog, give the page a meaningful `Caption` (the dialog heading) and let prompt-area input fields hide their own captions. Treat content-area fields like any other editable field — keep their captions. + +See sample: `show-caption-in-promptdialog-prompt-area.good.al`. diff --git a/microsoft/knowledge/ui/show-caption-in-repeater-allowed.good.al b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.good.al new file mode 100644 index 0000000..c463e71 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.good.al @@ -0,0 +1,25 @@ +page 50205 "UI Sample Repeater" +{ + PageType = List; + SourceTable = "Sales Line"; + + layout + { + area(Content) + { + repeater(Lines) + { + field(Description; Rec.Description) + { + ApplicationArea = All; + ShowCaption = false; + } + field(Amount; Rec.Amount) + { + ApplicationArea = All; + ShowCaption = false; + } + } + } + } +} diff --git a/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md new file mode 100644 index 0000000..b161149 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, repeater, column-header, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ShowCaption inside a repeater is harmless + +## Description + +Fields inside a `repeater()` control are labeled by their **column headers**, not by their own captions. `ShowCaption = false` on a field inside a repeater is harmless and should not be flagged. + +This is the explicit behaviour of the Business Central client: a repeater renders as a tabular list whose column headings come from each field's `Caption` (or source-table caption), and individual row cells do not announce a per-cell caption. + +## Best Practice + +Inside a repeater, you may set `ShowCaption = false` on fields without losing accessibility. The column header still provides the label for every cell in that column. Outside a repeater, the rules in `show-caption-on-editable-fields.md` apply. + +See sample: `show-caption-in-repeater-allowed.good.al`. diff --git a/microsoft/knowledge/ui/show-caption-on-editable-fields.bad.al b/microsoft/knowledge/ui/show-caption-on-editable-fields.bad.al new file mode 100644 index 0000000..ea39356 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-on-editable-fields.bad.al @@ -0,0 +1,27 @@ +page 50201 "UI Sample Editable Caption Bad" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + field("Customer Name"; Rec.Name) + { + ApplicationArea = All; + ShowCaption = false; + InstructionalText = 'Enter the customer name'; + } + field("Dynamic Editable"; Rec."No.") + { + ApplicationArea = All; + Editable = IsEditable; + ShowCaption = false; + } + } + } + + var + IsEditable: Boolean; +} diff --git a/microsoft/knowledge/ui/show-caption-on-editable-fields.good.al b/microsoft/knowledge/ui/show-caption-on-editable-fields.good.al new file mode 100644 index 0000000..9030dd4 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-on-editable-fields.good.al @@ -0,0 +1,16 @@ +page 50200 "UI Sample Editable Caption Good" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + field("Customer Name"; Rec.Name) + { + ApplicationArea = All; + } + } + } +} diff --git a/microsoft/knowledge/ui/show-caption-on-editable-fields.md b/microsoft/knowledge/ui/show-caption-on-editable-fields.md new file mode 100644 index 0000000..23075fd --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-on-editable-fields.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, editable, accessibility, label, instructional-text] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ShowCaption on editable fields + +## Description + +`ShowCaption` must remain true (the default) on editable fields unless the field matches one of the officially supported "magic patterns". Fields are editable by default. Setting `ShowCaption = false` on an editable field is almost always an accessibility bug: without a visible caption, screen reader users lose the label that identifies the field, and sighted users lose a visual cue. + +A field whose `Editable` property is a Boolean expression (e.g. `Editable = IsEditable`) is dynamically editable and must be treated as a form field — `ShowCaption = false` on such a field is also a violation. + +## Best Practice + +Leave `ShowCaption` at its default on editable fields. If a caption would be visually redundant, rely on one of the documented magic patterns (group-labeled first child, repeater column, PromptDialog prompt input) rather than removing the caption. + +See sample: `show-caption-on-editable-fields.good.al`. + +## Anti Pattern + +The `InstructionalText` property on a field renders as HTML placeholder text and is **not** a substitute for a caption — it disappears once the user types and is not reliably announced by screen readers. + +See sample: `show-caption-on-editable-fields.bad.al`. diff --git a/microsoft/knowledge/ui/standalone-content-in-layout-table.good.al b/microsoft/knowledge/ui/standalone-content-in-layout-table.good.al new file mode 100644 index 0000000..70f5213 --- /dev/null +++ b/microsoft/knowledge/ui/standalone-content-in-layout-table.good.al @@ -0,0 +1,39 @@ +page 50208 "UI Sample Standalone Content" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + grid(InfoGrid) + { + GridLayout = Columns; + group(LeftColumn) + { + field(Address; Rec.Address) + { + ApplicationArea = All; + } + field(City; Rec.City) + { + ApplicationArea = All; + } + } + group(RightColumn) + { + field(StatusMessage; StatusText) + { + ApplicationArea = All; + Editable = false; + ShowCaption = false; + } + } + } + } + } + + var + StatusText: Text; +} diff --git a/microsoft/knowledge/ui/standalone-content-in-layout-table.md b/microsoft/knowledge/ui/standalone-content-in-layout-table.md new file mode 100644 index 0000000..74a69b2 --- /dev/null +++ b/microsoft/knowledge/ui/standalone-content-in-layout-table.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, fixed, layout-table, standalone-content, show-caption, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Standalone content in a layout-table grid + +## Description + +A non-editable field with `ShowCaption = false` is acceptable inside a layout-table grid **only when** the field is **standalone content** — it displays a value that is meaningful on its own (for example a status message or a description) and is **not** intended to label or be labeled by another field in the grid. + +Layout tables have no `
` column headers, so a captionless field that is meant to participate in a tabular relationship with a neighbour has no accessible label at all. + +## Best Practice + +Reserve `ShowCaption = false` in a layout-table grid for non-editable, free-standing content cells. If a field's role is to label or annotate another field in the same grid, restructure the grid to meet the data-table conditions (see `grid-data-table-heuristic.md`) instead of hiding the caption. + +See sample: `standalone-content-in-layout-table.good.al`. diff --git a/microsoft/knowledge/ui/style-expr-text-vs-boolean.good.al b/microsoft/knowledge/ui/style-expr-text-vs-boolean.good.al new file mode 100644 index 0000000..3d09c2a --- /dev/null +++ b/microsoft/knowledge/ui/style-expr-text-vs-boolean.good.al @@ -0,0 +1,41 @@ +page 50214 "UI Sample StyleExpr" +{ + PageType = List; + SourceTable = "Sales Header"; + + layout + { + area(Content) + { + repeater(Lines) + { + field(Status; Rec.Status) + { + ApplicationArea = All; + StyleExpr = StatusStyle; + } + field(Amount; Rec.Amount) + { + ApplicationArea = All; + Style = Favorable; + StyleExpr = IsProfitable; + } + } + } + } + + trigger OnAfterGetRecord() + begin + case Rec.Status of + Rec.Status::Open: + StatusStyle := 'Standard'; + Rec.Status::Released: + StatusStyle := 'Favorable'; + end; + IsProfitable := Rec.Amount > 0; + end; + + var + StatusStyle: Text; + IsProfitable: Boolean; +} diff --git a/microsoft/knowledge/ui/style-expr-text-vs-boolean.md b/microsoft/knowledge/ui/style-expr-text-vs-boolean.md new file mode 100644 index 0000000..5040099 --- /dev/null +++ b/microsoft/knowledge/ui/style-expr-text-vs-boolean.md @@ -0,0 +1,25 @@ +--- +bc-version: [all] +domain: ui +keywords: [style-expr, style, boolean, text-variable, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# StyleExpr: Boolean toggle vs Text variable + +## Description + +`StyleExpr` on a page field serves two distinct purposes depending on its type: + +- **Boolean** — When `StyleExpr` is a Boolean expression, it controls whether the `Style` property is applied. In this case the `Style` property carries the style name; analyze `Style` and ignore `StyleExpr` itself. +- **Text** — When `StyleExpr` is a Text variable (e.g. `StyleExpr = StatusStyle` where `StatusStyle: Text` and is assigned literals such as `'Favorable'`), the variable contains the style name at runtime. There may be no `Style` property at all — the `StyleExpr` variable **is** the style. + +When `StyleExpr` is Text, you must trace the variable's assignments — typically in `OnAfterGetRecord` or `OnAfterGetCurrRecord` — to determine which styles can be applied, then apply the same accessibility rules as for a literal `Style` value. + +## Best Practice + +Inspect the declared type of the symbol referenced by `StyleExpr` before drawing conclusions. If it is Boolean, evaluate the `Style` property. If it is Text, follow every assignment to the variable and check the full set of possible style values against `cosmetic-styles-need-no-textual-context.md` and `semantic-styles-need-independent-textual-meaning.md`. + +See sample: `style-expr-text-vs-boolean.good.al`. diff --git a/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.bad.al b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.bad.al new file mode 100644 index 0000000..3fc52ec --- /dev/null +++ b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.bad.al @@ -0,0 +1,40 @@ +page 50209 "UI Sample Tabular Mix Bad" +{ + PageType = Card; + SourceTable = "Cust. Ledger Entry"; + + layout + { + area(Content) + { + grid(StatementGrid) + { + GridLayout = Columns; + group(Periods) + { + ShowCaption = false; + field(StatementPeriod; Rec."Posting Date") + { + ApplicationArea = All; + Editable = false; + ShowCaption = false; + } + } + group(Balances) + { + ShowCaption = false; + field(StatementBalance; Rec.Amount) + { + ApplicationArea = All; + Editable = false; + ShowCaption = false; + } + field(DueDate; Rec."Due Date") + { + ApplicationArea = All; + } + } + } + } + } +} diff --git a/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md new file mode 100644 index 0000000..b56aadb --- /dev/null +++ b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md @@ -0,0 +1,27 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, fixed, tabular-intent, data-table, accidental-mix, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Tabular intent requires data-table conditions + +## Description + +The most common accessibility bug in grid layouts is partially following the data-table conventions. A developer arranges fields with **tabular intent** — one field acts as a label or row header for another — but the grid does not satisfy all the data-table heuristic conditions. The client falls back to layout-table rendering, and the tabular relationships between fields are lost: a screen reader announces each field independently with no programmatic association. + +Flag a grid as an accessibility issue when any of these are true: + +- An editable field has `ShowCaption = false` and the grid does not meet all data-table conditions. +- Fields are arranged so that one field is clearly intended to label or describe another field (tabular data intent), but the grid does not meet all data-table conditions. + +Both manifestations have the same root cause: tabular semantics were intended but the heuristic ultimately rendered the grid as a layout table. + +## Anti Pattern + +A single field that keeps its visible caption is enough to demote an entire would-be data-table grid into a layout table — and silently strip the labels off its sibling captionless fields. Either restructure to meet all three conditions, or restore captions on every editable field. + +See sample: `tabular-intent-requires-data-table-conditions.bad.al`. diff --git a/microsoft/knowledge/ui/titles-have-no-trailing-punctuation.md b/microsoft/knowledge/ui/titles-have-no-trailing-punctuation.md deleted file mode 100644 index a076a76..0000000 --- a/microsoft/knowledge/ui/titles-have-no-trailing-punctuation.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [title, caption, page, dialog, punctuation, ellipsis] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Titles carry no trailing punctuation and no trailing ellipsis - -## Description - -Page titles, section titles, FastTab titles, and dialog titles in Business Central are labels, not sentences — they have no trailing period, question mark, or exclamation. Trailing ellipsis ("…" or "...") on a title is specifically a long-standing Windows convention for action buttons that open a dialog, and AL handles that via the action's runtime behaviour rather than the caption text. Adding the ellipsis literally into a page caption or action caption is wrong in both directions: the platform also displays its own ellipsis when appropriate, and the static three dots corrupt translations that adjust punctuation for the locale. - -## Best Practice - -End titles with the last word of the title. Sentence case per the capitalization rule for the phrase type (see `caption-capitalization-noun-phrase-vs-sentence-phrase`). If a dialog needs "…" behaviour, rely on the platform; do not type the characters into the caption string. - -## Anti Pattern - -`Caption = 'Setup wizard...'`, `Caption = 'Sales orders.'`, `page Caption = 'Customer list:'` — all three decorate the title with terminal punctuation that is noise to the reader and a translation headache. diff --git a/microsoft/knowledge/ui/tooltips-describe-teaching-tips-guide.md b/microsoft/knowledge/ui/tooltips-describe-teaching-tips-guide.md deleted file mode 100644 index da920e3..0000000 --- a/microsoft/knowledge/ui/tooltips-describe-teaching-tips-guide.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [tooltip, teaching-tip, abouttitle, abouttext, onboarding] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Tooltips describe what a thing is; teaching tips guide what the user can do with it - -## Description - -Business Central exposes two distinct affordances for explaining the UI: ToolTip and the AboutTitle/AboutText teaching tip. They answer different questions and are complementary, not alternatives. ToolTip answers "What is this field/action?" and is expected on every field and action. The teaching tip answers "What can I do with this page or this important element?" and is reserved for the few entry points where an onboarding hint is worth the user's attention. Authors who put teaching-tip content in tooltips make tooltips noisy; authors who put tooltip content in teaching tips make teaching tips useless. - -## Best Practice - -Write ToolTip as a concise descriptive sentence following the "Specifies …" or imperative voice rules. Reserve AboutTitle/AboutText for the top-level card and list pages where first-time users benefit from discovering the page's purpose and outcome. On list pages, title uses the plural form ("About sales invoices"). On card or document pages, title uses the entity name plus "details" ("About sales invoice details"). - -## Anti Pattern - -A field ToolTip that tells the user "You can create new customers from here and update their payment terms, and the list also shows…" — that is teaching-tip content. Conversely, an AboutText that simply repeats the page Caption tells the user nothing they did not already read in the title bar. diff --git a/microsoft/knowledge/ui/tour-tips-do-not-use-action-language.md b/microsoft/knowledge/ui/tour-tips-do-not-use-action-language.md deleted file mode 100644 index 026a005..0000000 --- a/microsoft/knowledge/ui/tour-tips-do-not-use-action-language.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [tour-tip, abouttext, teaching-tip, imperative, onboarding] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Tour tips describe outcomes, not instructions — never tell the user to perform an action during the tour - -## Description - -A tour is a guided sequence of teaching tips that runs over the page while the user is passively watching. The tour framework does not expose the page's actions during the tip — so an `AboutText` that tells the user `Enter the customer name here.` or `Now post the invoice.` asks the user to do something that is not possible in the moment. The result is a confusing first-run experience. Tour content should describe what the element represents and what the user will be able to do with it after the tour completes, in descriptive rather than imperative voice. - -## Best Practice - -Write tour AboutTitle as a short noun-phrase label for the element ("Who you are selling to", "When all is set, you post"). Write AboutText as one or two sentences that describe the outcome or meaning, not steps. Keep the tour itself short — one to four tips total — and let the regular ToolTip carry the per-element detail. - -## Anti Pattern - -`AboutText = 'Enter the customer name here.'` on a tour tip — the action is not active. `AboutText = 'Now post the invoice.'` during a tour — the user cannot, and would not want to mid-tour. Both teach nothing and confuse the reader. diff --git a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.bad.al b/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.bad.al deleted file mode 100644 index 8142b2f..0000000 --- a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.bad.al +++ /dev/null @@ -1,20 +0,0 @@ -page 51007 "UI Sample Ampersand Bad" -{ - PageType = Card; - SourceTable = "Sales Header"; - - actions - { - area(Processing) - { - action(PostAndSend) - { - // '&' is being used as "and", not as an accelerator prefix. The - // parser cannot tell; translators re-evaluate every occurrence. - Caption = 'Post & Send'; - ApplicationArea = All; - ToolTip = 'Post and send the document.'; - } - } - } -} diff --git a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.good.al b/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.good.al deleted file mode 100644 index 723a70b..0000000 --- a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.good.al +++ /dev/null @@ -1,19 +0,0 @@ -page 51006 "UI Sample Ampersand Good" -{ - PageType = Card; - SourceTable = "Sales Header"; - - actions - { - area(Processing) - { - action(PostAndSend) - { - // "and" written out. Ampersand-s marks 's' as the accelerator key. - Caption = 'Post and &send'; - ApplicationArea = All; - ToolTip = 'Post the document and send it to the customer.'; - } - } - } -} diff --git a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.md b/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.md deleted file mode 100644 index 02746fd..0000000 --- a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [ampersand, caption, accelerator, translation, voice] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Write "and" in UI captions; keep the ampersand only as an accelerator-key prefix - -## Description - -AL Caption strings use the ampersand character in two distinct ways. Inside a caption, `&` is the accelerator-key prefix — `Caption = '&Post'` underlines the P and makes Alt+P activate the action. Outside that role, `&` is sometimes used as a shortening for the word "and" (`Post & Send`). The first usage is platform-defined and must be preserved. The second is a style choice that the Business Central voice guidelines reject: `Post and send` reads naturally in all supported locales and translates cleanly, while `Post & Send` conveys nothing extra and adds a character that localizers have to re-evaluate. - -## Best Practice - -Use the word "and" in caption text. Keep `&` only when it is immediately followed by a letter chosen as the keyboard accelerator. If both meanings apply, write them explicitly: `Post and &send` uses `s` as the accelerator and spells the conjunction out. - -See sample: `use-and-not-ampersand-in-ui-captions.good.al`. - -## Anti Pattern - -`Caption = 'Post & Send'` as the full caption — the ampersand is meant as "and" but the AL parser cannot tell, and the result is inconsistent with every other "X and Y" caption in the product. - -See sample: `use-and-not-ampersand-in-ui-captions.bad.al`. diff --git a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.bad.al b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.bad.al deleted file mode 100644 index 2b34f6c..0000000 --- a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.bad.al +++ /dev/null @@ -1,24 +0,0 @@ -page 50733 "UI Grid Bad" -{ - layout - { - area(Content) - { - grid(BalanceGrid) - { - GridLayout = Columns; - field(CustomerName; Rec."Customer Name") - { - ShowCaption = false; - } - group(BalanceColumn) - { - field(Balance; Rec.Balance) - { - ShowCaption = false; - } - } - } - } - } -} diff --git a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.md b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.md deleted file mode 100644 index b30dcd1..0000000 --- a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [grid, fixed, showcaption, accessibility, table-semantics] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use the grid data-table pattern consistently - -## Description - -Business Central `grid` and `fixed` layouts render either as data tables or layout tables based on a structural heuristic. A data table requires all direct children to be groups, every group child to be a field, and all fields to have `ShowCaption = false`. If the structure fails that heuristic, the client renders a layout table; hidden captions on editable fields then remove the only accessible labels. - -## Best Practice - -Use one pattern consistently. For a data-table grid, make every direct child a group and every field `ShowCaption = false`. For a layout grid, keep captions visible on editable or tabular fields and hide captions only on standalone non-editable content where the missing label is not a form-field problem. - -See sample: `use-grid-data-table-pattern-consistently.good.al`. - -## Anti Pattern - -Mixing the patterns: one loose field, nested group, or visible field caption prevents data-table rendering, while other editable fields still hide captions. The result looks like a table visually but has layout-table semantics and missing labels for assistive technology. - -See sample: `use-grid-data-table-pattern-consistently.bad.al`. diff --git a/microsoft/knowledge/upgrade/assess-existing-data-before-key-or-type-changes.md b/microsoft/knowledge/upgrade/assess-existing-data-before-key-or-type-changes.md deleted file mode 100644 index 63ffed1..0000000 --- a/microsoft/knowledge/upgrade/assess-existing-data-before-key-or-type-changes.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [primary-key, field-type, existing-data, schema, breaking-change] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Assess existing data before primary-key or field-type changes - -## Description - -Primary-key and field-type changes are upgrade concerns because existing rows may no longer map safely to the new schema. The risk depends on whether the table already has tenant data and whether the old values can be converted without loss. New feature tables with no production rows do not have the same migration burden as ledger, document, or base application tables. - -## Best Practice - -For existing tables with data, require a concrete migration or compatibility assessment before changing keys or field types. For new tables, new feature tables, or Integer-to-BigInteger changes with evidence that existing values fit, avoid flagging a breaking-change finding without data-impact evidence. - -## Anti Pattern - -Treating every primary-key edit in a new feature table as a blocker while missing a key or type change on an established ledger-like table. Reviewers need to tie the finding to existing tenant data, not just to the syntactic shape of the schema edit. diff --git a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.bad.al b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.bad.al new file mode 100644 index 0000000..00ed5e3 --- /dev/null +++ b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.bad.al @@ -0,0 +1,15 @@ +// A pre-existing table with millions of rows. Changing the primary key or +// widening a field type without an upgrade plan can fail at deployment. +tableextension 50233 "Cust Ledger Entry Ext" extends "Cust. Ledger Entry" +{ + fields + { + // Widening Integer to BigInteger on an existing column with persisted data + // requires an upgrade plan and value-range evidence; not safe as a bare edit. + modify("Entry No.") + { + // (hypothetical: field type change goes here) + } + } + // No accompanying upgrade codeunit, no upgrade tag, no overflow verification. +} diff --git a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.good.al b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.good.al new file mode 100644 index 0000000..455477a --- /dev/null +++ b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.good.al @@ -0,0 +1,16 @@ +// New feature table introduced in the same change as the keys / field types. +// No existing data, so the layout is free to choose. +table 50232 "New Feature Table" +{ + fields + { + field(1; "Entry No."; BigInteger) { } + field(2; "Customer No."; Code[20]) { } + field(3; "Posting Date"; Date) { } + } + keys + { + key(PK; "Entry No.") { Clustered = true; } + key(ByCustomer; "Customer No.", "Posting Date") { } + } +} diff --git a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md new file mode 100644 index 0000000..9ba7e8a --- /dev/null +++ b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [primary-key, field-type, breaking-change, integer-to-biginteger, existing-data] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Primary-key and field-type changes are safe only on tables without existing data + +## Description + +Primary-key changes and field-type changes (for example widening `Integer` to `BigInteger`) rewrite the on-disk layout of every row in the table. On a new feature table that ships in the same change as the modification, no rows exist and the change is free. On an existing table that already holds tenant data — base-app tables, ledger entries, anything that has been live across releases — the same change can fail outright (key uniqueness violations, value overflow on conversion) or require a full table rewrite during the upgrade window. Either way, the change needs an explicit migration design, not just a metadata edit. + +## Best Practice + +Treat primary-key and field-type changes as restricted to tables introduced in the same change. For changes on tables with existing data, design and ship the corresponding upgrade procedure (typically backed by `DataTransfer` and an upgrade tag) that guarantees the new layout is achievable for every row, and verify with concrete evidence that the existing values fit the new constraint (no PK collisions, no value-range overflow). + +See sample: `breaking-changes-only-on-tables-without-data.good.al`. + +## Anti Pattern + +Changing the primary key on a base-app table, or widening / narrowing a field type on a table that has been shipping for releases, with no accompanying upgrade plan. The change compiles cleanly and may even deploy on an empty-ish tenant, then fails on customers who actually have data. + +See sample: `breaking-changes-only-on-tables-without-data.bad.al`. diff --git a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.bad.al b/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.bad.al deleted file mode 100644 index 1d83a48..0000000 --- a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50801 "Upgrade Sample CallMethods Bad" -{ - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - var - Customer: Record Customer; - begin - // Inline logic in the trigger body: no tag guard, not testable in isolation, - // re-runs on every upgrade. - Customer.SetRange(Blocked, Customer.Blocked::" "); - Customer.ModifyAll("Some Field", true); - end; -} diff --git a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.good.al b/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.good.al deleted file mode 100644 index e5c7218..0000000 --- a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.good.al +++ /dev/null @@ -1,31 +0,0 @@ -codeunit 50800 "Upgrade Sample CallMethods Good" -{ - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - begin - UpgradeCustomerDefaults(); - UpgradeSalesDocumentDefaults(); - end; - - local procedure UpgradeCustomerDefaults() - var - UpgradeTag: Codeunit "Upgrade Tag"; - begin - if UpgradeTag.HasUpgradeTag(CustomerDefaultsUpgradeTag()) then - exit; - - // Step body omitted - - UpgradeTag.SetUpgradeTag(CustomerDefaultsUpgradeTag()); - end; - - local procedure UpgradeSalesDocumentDefaults() - begin - end; - - local procedure CustomerDefaultsUpgradeTag(): Code[250] - begin - exit('MS-000001-CustomerDefaults-20260501'); - end; -} diff --git a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.md b/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.md deleted file mode 100644 index cf04b13..0000000 --- a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [upgrade-codeunit, onupgradepercompany, onupgradeperdatabase, structure] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Call named methods from OnUpgrade triggers; keep the triggers empty of logic - -## Description - -An upgrade codeunit (`Subtype = Upgrade`) runs its triggers once per upgrade scope. Inlining upgrade logic inside the trigger body mixes the entry point with the work, makes individual steps untestable in isolation, and prevents the standard upgrade-tag guard pattern from being applied cleanly. The convention across Business Central's own upgrade codeunits is that `OnUpgradePerCompany` and `OnUpgradePerDatabase` are a list of calls to named local procedures, each implementing one step behind its own upgrade-tag check. - -## Best Practice - -Keep `OnUpgradePerCompany` and `OnUpgradePerDatabase` to a list of `UpgradeXxx();` statements. Put every data migration, default, or correction in a named local procedure whose first action is the upgrade-tag guard. Empty trigger bodies are also acceptable as placeholders on a new codeunit with no current steps. - -See sample: `call-methods-from-onupgrade-triggers-not-inline-code.good.al`. - -## Anti Pattern - -Writing `Customer.ModifyAll(...)`, `TableX.SetRange(...)` + loops, or `DataTransfer.CopyFields()` directly inside the trigger body. The step is untagged, untestable, and re-runs on every upgrade. - -See sample: `call-methods-from-onupgrade-triggers-not-inline-code.bad.al`. diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.bad.al b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.bad.al new file mode 100644 index 0000000..30c601a --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.bad.al @@ -0,0 +1,23 @@ +codeunit 50219 "Upgrade Price List Source" +{ + Subtype = Upgrade; + + local procedure UpdatePriceSourceGroupInPriceListLines() + var + PriceListLine: Record "Price List Line"; + begin + // One round-trip per row across a potentially large table. + PriceListLine.SetRange("Source Group", "Price Source Group"::All); + if PriceListLine.FindSet(true) then + repeat + if PriceListLine."Source Type" in + ["Price Source Type"::"All Jobs", + "Price Source Type"::Job, + "Price Source Type"::"Job Task"] + then begin + PriceListLine."Source Group" := "Price Source Group"::Job; + PriceListLine.Modify(); + end; + until PriceListLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.good.al b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.good.al new file mode 100644 index 0000000..119e9f0 --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.good.al @@ -0,0 +1,23 @@ +codeunit 50218 "Upgrade Price List Source" +{ + Subtype = Upgrade; + + local procedure UpdatePriceSourceGroupInPriceListLines() + var + PriceListLine: Record "Price List Line"; + PriceListLineDataTransfer: DataTransfer; + begin + PriceListLineDataTransfer.SetTables(Database::"Price List Line", Database::"Price List Line"); + PriceListLineDataTransfer.AddSourceFilter( + PriceListLine.FieldNo("Source Group"), '=%1', "Price Source Group"::All); + PriceListLineDataTransfer.AddSourceFilter( + PriceListLine.FieldNo("Source Type"), '%1|%2|%3', + "Price Source Type"::"All Jobs", + "Price Source Type"::Job, + "Price Source Type"::"Job Task"); + PriceListLineDataTransfer.AddConstantValue( + "Price Source Group"::Job, PriceListLine.FieldNo("Source Group")); + PriceListLineDataTransfer.CopyFields(); + Clear(PriceListLineDataTransfer); + end; +} diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md new file mode 100644 index 0000000..3eeaa46 --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [datatransfer, large-dataset, bulk-update, modifyall, copyfields, new-field] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use `DataTransfer` for bulk updates on large tables + +## Description + +Tables that can contain more than 300,000 records, and any newly added field on an existing table, should be initialized with `DataTransfer` rather than a `repeat ... Modify ... until Next() = 0` loop. `DataTransfer` issues a single set-based statement to the database; the loop/modify pattern issues one round-trip per row and accumulates write locks for the duration of the upgrade. On the volumes that drive upgrade pain — ledger entries, item ledger entries, price list lines — the difference is the upgrade running for minutes instead of hours. + +## Best Practice + +For a bulk update use a `DataTransfer` variable: call `SetTables(Database::"...", Database::"...")` (source and destination may be the same table), add filters with `AddSourceFilter`, set the target value with `AddConstantValue` (or copy a source field with `AddFieldValue`), and execute with `CopyFields()`. To express multiple distinct updates against the same table, `Clear` the `DataTransfer` between executions and configure the next one. + +See sample: `datatransfer-for-bulk-init.good.al`. + +## Anti Pattern + +Iterating with `FindSet(true) ... repeat ... Modify() ... until Next() = 0` to set a single field across an entire large table. On 300k+ rows this is the canonical slow-upgrade footgun. + +See sample: `datatransfer-for-bulk-init.bad.al`. + +## See also + +- `datatransfer-skips-triggers-and-subscribers.md` — `DataTransfer` does not raise field validation triggers or event subscribers; if a row needs validation logic, `DataTransfer` is the wrong tool. diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al new file mode 100644 index 0000000..800c828 --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al @@ -0,0 +1,16 @@ +codeunit 50221 "Upgrade Existing Field" +{ + Subtype = Upgrade; + + local procedure UpdateCustomerCreditLimit() + var + Customer: Record Customer; + DT: DataTransfer; + begin + // "Credit Limit (LCY)" has OnValidate logic that recalculates risk fields + // and notifies subscribers. DataTransfer skips both — derived data drifts. + DT.SetTables(Database::Customer, Database::Customer); + DT.AddConstantValue(50000, Customer.FieldNo("Credit Limit (LCY)")); + DT.CopyFields(); + end; +} diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al new file mode 100644 index 0000000..0475079 --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al @@ -0,0 +1,16 @@ +codeunit 50220 "Upgrade New Field Init" +{ + Subtype = Upgrade; + + local procedure InitializeNewFlagOnMyTable() + var + MyTable: Record "My Table"; + DT: DataTransfer; + begin + // "New Flag" is added in the same change as this upgrade procedure. + // No existing validation logic depends on it, so DataTransfer is safe. + DT.SetTables(Database::"My Table", Database::"My Table"); + DT.AddConstantValue(true, MyTable.FieldNo("New Flag")); + DT.CopyFields(); + end; +} diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md new file mode 100644 index 0000000..785684f --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [datatransfer, validate-trigger, event-subscriber, side-effects, business-logic] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `DataTransfer` does not fire validation triggers or event subscribers + +## Description + +`DataTransfer` writes directly at the database layer. It does not invoke field `OnValidate` triggers, table `OnModify` triggers, or any `OnAfterModifyEvent` / `OnBeforeValidate...` event subscribers that a normal `Record.Modify(true)` would. This is precisely what makes it fast — and precisely what makes it a footgun when the field being updated has validation logic that other code relies on. The receiving code never gets the signal that a row changed, derived fields stay stale, audit hooks do not run. + +For *new fields and tables added in the same change* this is fine: nothing yet depends on the validation. For *pre-existing fields with validation logic*, `DataTransfer` quietly bypasses business logic that may be load-bearing for posting, calculation, or integration scenarios. + +## Best Practice + +Use `DataTransfer` only when the field or table is new in the same change — initial population is the canonical safe case. When updating a pre-existing field that has validation logic, either use `Modify(true)` to honour the triggers, or, if `DataTransfer` is still required for performance reasons, leave a comment that explicitly states "validation triggers and event subscribers are intentionally not raised" and verify with the field's owner that this is safe. + +See sample: `datatransfer-skips-triggers-and-subscribers.good.al`. + +## Anti Pattern + +Reaching for `DataTransfer` to update an existing field with non-trivial `OnValidate` logic, without a comment and without confirming that subscribers can be skipped. The upgrade succeeds; runtime behaviour drifts silently. + +See sample: `datatransfer-skips-triggers-and-subscribers.bad.al`. diff --git a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.bad.al b/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.bad.al deleted file mode 100644 index e895201..0000000 --- a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.bad.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50822 "Upgrade Sample FirstInstall Bad" -{ - Subtype = Install; - - trigger OnInstallAppPerCompany() - begin - // Unconditional initialization. Re-install after uninstall either throws - // on primary-key collisions or overwrites existing rows. - InsertDefaultSetup(); - end; - - local procedure InsertDefaultSetup() - begin - end; -} diff --git a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.md b/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.md deleted file mode 100644 index 84d72fb..0000000 --- a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [oninstall, dataversion, appinfo, first-install, upgrade-tag] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Detect first install via DataVersion equal to 0.0.0.0 in OnInstall triggers - -## Description - -`OnInstallAppPerCompany` fires on first install and on subsequent re-installs after an uninstall. Code that should only run on the very first install needs to distinguish the two — and the supported way is checking `AppInfo.DataVersion() = Version.Create('0.0.0.0')`, which is the sentinel for "no prior data exists for this app in this tenant". This is the one case where a DataVersion check is correct; steady-state upgrade steps should use upgrade tags instead. - -## Best Practice - -In `OnInstallAppPerCompany`, call `NavApp.GetCurrentModuleInfo(AppInfo)` and exit early when `AppInfo.DataVersion()` is non-zero. The remainder of the trigger body then runs exclusively on first install. For all other version-sensitive upgrade logic, use upgrade tags (see `use-upgrade-tags-not-version-checks`). - -See sample: `detect-first-install-via-dataversion-zero.good.al`. - -## Anti Pattern - -Running initialization unconditionally in `OnInstallAppPerCompany` and relying on primary-key collisions to avoid double-inserts. Re-install scenarios either throw or overwrite existing rows; the install path becomes brittle as the app grows. - -See sample: `detect-first-install-via-dataversion-zero.bad.al`. diff --git a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.bad.al b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.bad.al new file mode 100644 index 0000000..2c200c6 --- /dev/null +++ b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.bad.al @@ -0,0 +1,17 @@ +codeunit 50207 "Upgrade Graceful" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeCustomerLink('C00010'); + end; + + local procedure UpgradeCustomerLink(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + // Throws if the record is missing — aborts the upgrade. + Customer.Get(CustomerNo); + end; +} diff --git a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al new file mode 100644 index 0000000..7a83df2 --- /dev/null +++ b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al @@ -0,0 +1,26 @@ +codeunit 50206 "Upgrade Graceful" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeCustomerLink('C00010'); + end; + + local procedure UpgradeCustomerLink(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if not Customer.Get(CustomerNo) then begin + Session.LogMessage( + '0000ABC', + 'Customer not found during upgrade', + Verbosity::Warning, + DataClassification::SystemMetadata, + TelemetryScope::ExtensionPublisher, + 'CustomerNo', CustomerNo); + exit; + end; + // Continue upgrade work using Customer ... + end; +} diff --git a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md new file mode 100644 index 0000000..cf585eb --- /dev/null +++ b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [error-handling, telemetry, session-logmessage, blocking, graceful] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Log telemetry; do not raise errors that block the upgrade + +## Description + +When upgrade code encounters unexpected data — a record it expected to find, a relationship it assumed to be intact — the response is to log telemetry and continue, not to raise an error. A runtime error inside an upgrade codeunit aborts the upgrade for the company or database, leaving the customer stuck on the old version. Customers should not be blocked from upgrading because of a data inconsistency that an upgrade routine could not have anticipated. + +## Best Practice + +When an upgrade procedure detects something missing, call `Session.LogMessage` with a stable event ID, classify the message verbosity (typically `Warning`), and `exit` the procedure so the rest of the upgrade can proceed. The platform telemetry then surfaces the situation to the partner without breaking the customer. + +See sample: `do-not-block-upgrade-on-data-errors.good.al`. + +## Anti Pattern + +Calling `Record.Get(Key)` (or any other erroring API) and letting the error propagate out of the upgrade trigger. The first tenant with imperfect data fails to upgrade, and the failure surfaces as a hard upgrade error rather than as a telemetry signal. + +See sample: `do-not-block-upgrade-on-data-errors.bad.al`. diff --git a/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md b/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md deleted file mode 100644 index e7bf1e6..0000000 --- a/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [upgrade, httpclient, external-service, dotnet, availability] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not make external service calls inside upgrade codeunits - -## Description - -The upgrade scope has to complete for the tenant to reach the new version. Any call in the upgrade path that depends on an external service — HttpClient to a partner API, a DotNet interop call, a codeunit that fetches remote configuration — fails closed when the service is unreachable, misconfigured, or slow. The failure blocks the upgrade for every customer whose environment cannot reach the dependency at the moment the upgrade runs, and there is no user present to retry. The scope is specifically code inside codeunits with `Subtype = Upgrade` or reachable from their triggers. - -## Best Practice - -Defer external calls to runtime code that executes after the upgrade — install-triggered tasks, background job queue entries scheduled by the upgrade, or lazy initialization on first use. The upgrade step should compute a local result or mark work to be done, not perform the remote call itself. Do not apply this rule to ordinary runtime codeunits, pages, tables, install procedures, or background jobs unless they are directly invoked from an upgrade trigger. - -## Anti Pattern - -`HttpClient.Get(...)` or `DotNetType.CallStaticMethod(...)` directly in `OnUpgradePerCompany`, or in a local procedure called from it. The upgrade now depends on network availability to a service the platform does not control. diff --git a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.bad.al b/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.bad.al deleted file mode 100644 index 0eb1590..0000000 --- a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.bad.al +++ /dev/null @@ -1,23 +0,0 @@ -enum 50815 "Upgrade Sample EnumInsert Bad" -{ - Extensible = true; - - value(0; First) { Caption = 'First'; } - - // Inserting at ordinal 1 shifts everything below. Every row that stored - // ordinal 1 before now resolves to NewMiddleValue. - value(1; NewMiddleValue) { Caption = 'New middle value'; } - - value(2; Second) { Caption = 'Second'; } - value(3; Third) { Caption = 'Third'; } -} - -enum 50816 "Upgrade Sample EnumRemove Bad" -{ - Extensible = true; - - value(0; First) { Caption = 'First'; } - // value(1; Second) removed without obsoletion. - // Existing rows storing ordinal 1 no longer resolve to any declared value. - value(2; Third) { Caption = 'Third'; } -} diff --git a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.good.al b/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.good.al deleted file mode 100644 index 073a9c6..0000000 --- a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.good.al +++ /dev/null @@ -1,29 +0,0 @@ -enum 50813 "Upgrade Sample EnumAdditive Good" -{ - Extensible = true; - - value(0; First) { Caption = 'First'; } - value(1; Second) { Caption = 'Second'; } - value(2; Third) { Caption = 'Third'; } - - // New value appended at the next free ordinal. Existing stored ordinals - // (0, 1, 2) keep their meaning. - value(3; NewValue) { Caption = 'New value'; } -} - -enum 50814 "Upgrade Sample EnumRetire Good" -{ - Extensible = true; - - value(0; First) { Caption = 'First'; } - - value(1; Second) - { - Caption = 'Second'; - ObsoleteState = Removed; - ObsoleteReason = 'Replaced by NewValue.'; - ObsoleteTag = '28.0'; - } - - value(2; Third) { Caption = 'Third'; } -} diff --git a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md b/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md deleted file mode 100644 index 5f2ef7e..0000000 --- a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [enum, ordinal, obsolete, backward-compatibility, breaking-change] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Enum changes must be additive at the end; never insert or remove values - -## Description - -AL enums store their ordinal on disk. Inserting a new value in the middle of an existing enum shifts every following ordinal by one: every row whose field holds the old ordinal N now resolves to the value that used to be N+1. Removing a value without obsoletion has the same effect. Both changes are data corruption disguised as a code edit and are effectively irreversible once a tenant has upgraded. Adding values at the end is safe — existing ordinals keep their meaning. - -## Best Practice - -Append new enum values at the end, taking the next free ordinal. Renaming the caption on an existing ordinal is fine. - -When a value must be retired, follow the two-stage obsoletion workflow: - -1. **First release:** Mark the value with `ObsoleteState = Pending`, `ObsoleteReason`, and `ObsoleteTag`. This gives callers at least one release cycle to migrate. -2. **Later release:** Advance to `ObsoleteState = Removed` once all callers have been updated. - -Never skip straight to `ObsoleteState = Removed` without first going through `Pending` — doing so removes the warning cycle that callers depend on. Do not reclaim the ordinal in either stage. See also: `use-obsolete-pending-before-removed.md`. - -See sample: `enum-changes-must-be-additive-at-the-end.good.al`. - -## Anti Pattern - -Inserting `value(1; "NewMiddleValue")` between existing `value(0; "First")` and the original `value(1; "Second")`. Every row that stored ordinal 1 before the change now reads as `NewMiddleValue`. The same applies to removing a value outright without obsoletion. - -See sample: `enum-changes-must-be-additive-at-the-end.bad.al`. diff --git a/microsoft/knowledge/upgrade/enum-values-additive-at-end.bad.al b/microsoft/knowledge/upgrade/enum-values-additive-at-end.bad.al new file mode 100644 index 0000000..ad16066 --- /dev/null +++ b/microsoft/knowledge/upgrade/enum-values-additive-at-end.bad.al @@ -0,0 +1,11 @@ +enum 50226 "My Enum" +{ + Extensible = true; + + value(0; "First") { } + value(1; "NewMiddleValue") { } // Inserted in the middle — shifts ordinals. + value(2; "Second") { } + value(3; "Third") { } + // Or: a previously declared value(1; "Second") removed without obsoletion — + // any persisted "1" now maps to whatever currently occupies ordinal 1. +} diff --git a/microsoft/knowledge/upgrade/enum-values-additive-at-end.good.al b/microsoft/knowledge/upgrade/enum-values-additive-at-end.good.al new file mode 100644 index 0000000..a585a2c --- /dev/null +++ b/microsoft/knowledge/upgrade/enum-values-additive-at-end.good.al @@ -0,0 +1,9 @@ +enum 50225 "My Enum" +{ + Extensible = true; + + value(0; "First") { } + value(1; "Second") { } + value(2; "Third") { } + value(3; "NewValue") { } // Appended at the end — no existing ordinal shifts. +} diff --git a/microsoft/knowledge/upgrade/enum-values-additive-at-end.md b/microsoft/knowledge/upgrade/enum-values-additive-at-end.md new file mode 100644 index 0000000..4de929b --- /dev/null +++ b/microsoft/knowledge/upgrade/enum-values-additive-at-end.md @@ -0,0 +1,31 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [enum, ordinal, additive, append, backward-compatible, breaking-change] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Add new enum values only at the end + +## Description + +An AL `enum` is a fixed list of ordinal-named values. Persisted rows reference enum members by ordinal, not by name. The only enum mutation that preserves the meaning of every existing row is **appending a new value at the end** — every previously valid ordinal still maps to the same member. Inserting a new value in the middle, renumbering existing values, or removing a value without obsoletion all shift ordinals: rows written with the old layout silently take on the new member at their saved ordinal. + +## Best Practice + +When adding an enum value, place it after the last existing `value(N; ...)` entry, with an ordinal strictly greater than every existing one. Never renumber existing entries. To retire a value, do not delete it: mark it `ObsoleteState = Pending` (and later `Removed`) with `ObsoleteReason` and `ObsoleteTag` so the ordinal remains taken. + +See sample: `enum-values-additive-at-end.good.al`. + +## Anti Pattern + +Inserting a value between existing entries ("just put `NewMiddleValue` between `First` and `Second`"), or removing a value from the enum without first going through `ObsoleteState = Pending` → `Removed`. Every row whose persisted ordinal matched the removed or shifted value now reads as a different member. + +See sample: `enum-values-additive-at-end.bad.al`. + +## See also + +- `obsoletion-requires-reason-and-tag.md` — how to retire an enum member correctly. +- `obsolete-pending-to-removed-staging.md` — the `Pending → Removed` lifecycle. diff --git a/microsoft/knowledge/upgrade/exclude-hybrid-migration-codeunits-from-standard-upgrade-rules.md b/microsoft/knowledge/upgrade/exclude-hybrid-migration-codeunits-from-standard-upgrade-rules.md deleted file mode 100644 index 66bad0a..0000000 --- a/microsoft/knowledge/upgrade/exclude-hybrid-migration-codeunits-from-standard-upgrade-rules.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [hybrid, migration, upgrade-tag, false-positive, datamigration] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Exclude Hybrid migration codeunits from standard upgrade rules - -## Description - -Hybrid migration codeunits such as `HybridBC14`, `HybridSL`, `HybridGP`, and `HybridBaseDeployment` are one-time migration paths with established migration-specific patterns. They are not ordinary `Subtype = Upgrade` steps, and forcing standard upgrade-tag, trigger-shape, or missing-upgrade-code rules onto them creates false positives. - -## Best Practice - -When a change is clearly in a Hybrid migration codeunit or migration namespace, review it against migration-specific data handling and destination classification rules. Do not flag it merely because it lacks ordinary upgrade tags or because its control flow differs from standard upgrade codeunits. - -## Anti Pattern - -Reporting "missing upgrade tag" or "missing standard upgrade code" on a `HybridSL`, `HybridGP`, `HybridBC`, or `HybridBaseDeployment` codeunit solely because it does not look like a normal upgrade step. The name and migration context are the signal that different rules apply. diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al new file mode 100644 index 0000000..e3381ad --- /dev/null +++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al @@ -0,0 +1,13 @@ +codeunit 50211 "Install My Extension" +{ + Subtype = Install; + + trigger OnInstallAppPerCompany() + begin + // No DataVersion() guard — this runs on every reinstall and upgrade + // path, duplicating seed rows. + SeedDefaultRows(); + end; + + local procedure SeedDefaultRows() begin end; +} diff --git a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.good.al b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.good.al similarity index 55% rename from microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.good.al rename to microsoft/knowledge/upgrade/first-install-dataversion-zero-check.good.al index 174e2d9..9d6e92e 100644 --- a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.good.al +++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.good.al @@ -1,4 +1,4 @@ -codeunit 50821 "Upgrade Sample FirstInstall Good" +codeunit 50210 "Install My Extension" { Subtype = Install; @@ -10,11 +10,6 @@ codeunit 50821 "Upgrade Sample FirstInstall Good" if AppInfo.DataVersion() <> Version.Create('0.0.0.0') then exit; - // First-install-only initialization follows here. - InsertDefaultSetup(); - end; - - local procedure InsertDefaultSetup() - begin + // Install-only seed code goes here. end; } diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md new file mode 100644 index 0000000..260b15b --- /dev/null +++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [dataversion, first-install, on-install-app-per-company, moduleinfo, zero-version] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Detect first install with `DataVersion() = Version.Create('0.0.0.0')` + +## Description + +On the first install of an extension on a tenant the platform records a zero data version: `AppInfo.DataVersion()` returns `Version.Create('0.0.0.0')`. Subsequent upgrades record the actual previous version. The `OnInstallAppPerCompany` trigger uses this distinction to detect a brand-new install — for example, to seed default rows that should not be re-inserted on a normal upgrade. This is the one place where reading `DataVersion()` is the right tool; for everything else, use an upgrade tag. + +## Best Practice + +In `OnInstallAppPerCompany`, fetch the current `ModuleInfo` via `NavApp.GetCurrentModuleInfo`, compare `AppInfo.DataVersion()` to `Version.Create('0.0.0.0')`, and run install-only seed logic only when they match. On any non-zero data version, exit immediately — that path is an upgrade, not an install. + +See sample: `first-install-dataversion-zero-check.good.al`. + +## Anti Pattern + +Treating `OnInstallAppPerCompany` as if it always implies "fresh tenant". The trigger also fires when reinstalling over an existing data set; without the `0.0.0.0` guard, install-only seed code re-runs on every upgrade and duplicates rows. + +See sample: `first-install-dataversion-zero-check.bad.al`. + +## See also + +- `use-upgrade-tags-not-version-checks.md` — for upgrade steps after first install, use upgrade tags rather than `DataVersion`. diff --git a/microsoft/knowledge/upgrade/guard-database-reads.bad.al b/microsoft/knowledge/upgrade/guard-database-reads.bad.al new file mode 100644 index 0000000..0c28fa6 --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-database-reads.bad.al @@ -0,0 +1,20 @@ +codeunit 50205 "Upgrade Guarded Reads" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + local procedure UpgradeMyFeature() + var + Item: Record Item; + Customer: Record Customer; + Vendor: Record Vendor; + begin + Item.Get('1000'); // Throws if missing; aborts upgrade. + Customer.FindSet(); // Throws if empty. + Vendor.FindLast(); // Throws if empty. + end; +} diff --git a/microsoft/knowledge/upgrade/guard-database-reads.good.al b/microsoft/knowledge/upgrade/guard-database-reads.good.al new file mode 100644 index 0000000..868f2ae --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-database-reads.good.al @@ -0,0 +1,22 @@ +codeunit 50204 "Upgrade Guarded Reads" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + local procedure UpgradeMyFeature() + var + Item: Record Item; + Customer: Record Customer; + Vendor: Record Vendor; + begin + if Item.Get('1000') then + Item.Modify(); + if Customer.FindSet() then; + if not Vendor.FindLast() then + exit; + end; +} diff --git a/microsoft/knowledge/upgrade/guard-database-reads.md b/microsoft/knowledge/upgrade/guard-database-reads.md new file mode 100644 index 0000000..c5bc206 --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-database-reads.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [get, findset, findlast, guard, if-then, runtime-error] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Guard every database read in upgrade code with `if` + +## Description + +Inside an upgrade codeunit (or any procedure transitively invoked from `OnUpgradePerCompany` / `OnUpgradePerDatabase`), an unguarded `Record.Get`, `Record.FindSet`, or `Record.FindLast` raises a runtime error when the row or set is missing. In upgrade context that error aborts the entire upgrade for the company or database — a far worse outcome than the missing data itself. Records the upgrade reasons about may legitimately not exist on every customer's tenant. + +## Best Practice + +Wrap every read in an `if`. `if Item.Get(No) then ...`, `if Customer.FindSet() then;`, `if not Vendor.FindLast() then exit;`. The empty-then form `if Customer.FindSet() then;` is the idiomatic way to attempt a read whose only purpose is to position a record, while swallowing the "not found" case. + +See sample: `guard-database-reads.good.al`. + +## Anti Pattern + +Calling `Item.Get()`, `Customer.FindSet()`, or `Vendor.FindLast()` bare in upgrade code. The first tenant whose data does not match the upgrade's assumptions will fail to upgrade. + +See sample: `guard-database-reads.bad.al`. diff --git a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.bad.al b/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.bad.al deleted file mode 100644 index 2003dab..0000000 --- a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.bad.al +++ /dev/null @@ -1,19 +0,0 @@ -codeunit 50807 "Upgrade Sample GuardReads Bad" -{ - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - var - Setup: Record "Sales & Receivables Setup"; - Customer: Record Customer; - begin - // Unguarded Get. One tenant whose Setup row is missing blocks the upgrade. - Setup.Get(); - - // Unguarded FindSet. Raises when the table is empty for this tenant. - Customer.FindSet(); - repeat - // per-row work - until Customer.Next() = 0; - end; -} diff --git a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.good.al b/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.good.al deleted file mode 100644 index f98f45d..0000000 --- a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.good.al +++ /dev/null @@ -1,23 +0,0 @@ -codeunit 50806 "Upgrade Sample GuardReads Good" -{ - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - begin - UpgradeDefaults(); - end; - - local procedure UpgradeDefaults() - var - Setup: Record "Sales & Receivables Setup"; - Customer: Record Customer; - begin - if not Setup.Get() then - exit; - - if Customer.FindSet() then - repeat - // per-row work - until Customer.Next() = 0; - end; -} diff --git a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.md b/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.md deleted file mode 100644 index f3c6f99..0000000 --- a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [upgrade, get, findset, findlast, guard, unblocking] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Guard every database read in upgrade codeunits; never let a missing row block the upgrade - -## Description - -An unguarded `Record.Get()` raises when the row does not exist; an unguarded `FindSet()` or `FindLast()` raises when the result set is empty. In ordinary runtime code those errors surface to a user who can retry. In an upgrade codeunit they abort the upgrade of the tenant and the customer is blocked from getting to the new version. Real-world data is inconsistent enough — missing lookup rows, empty setup tables, skipped modules — that an unguarded read reliably blocks at least one customer per release. - -## Best Practice - -Wrap every Get, FindSet, FindFirst, FindLast, and related call in an `if … then` guard. On the not-found path, either exit the current step or log telemetry and continue; never let the upgrade scope raise. `if Customer.FindSet() then;` (statement terminator as the entire body) is an acceptable pattern when only the side effect of positioning matters. - -See sample: `guard-every-database-read-in-upgrade-codeunits.good.al`. - -## Anti Pattern - -`Customer.Get(CustomerNo);` or `SalesHeader.FindLast();` inside an upgrade procedure. One missing row in one tenant turns every future upgrade into a support ticket. - -See sample: `guard-every-database-read-in-upgrade-codeunits.bad.al`. diff --git a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.bad.al b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.bad.al deleted file mode 100644 index 08f88b1..0000000 --- a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50831 "Upgrade Sample Trigger Bad" -{ - Subtype = Upgrade; - - trigger OnValidateUpgradePerCompany() - begin - ValidateAllCustomers(); - end; - - local procedure ValidateAllCustomers() - begin - end; -} diff --git a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.good.al b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.good.al deleted file mode 100644 index 7e29e43..0000000 --- a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.good.al +++ /dev/null @@ -1,25 +0,0 @@ -codeunit 50830 "Upgrade Sample Trigger Good" -{ - Subtype = Upgrade; - - trigger OnValidateUpgradePerCompany() - var - UpgradeTag: Codeunit "Upgrade Tag"; - begin - // Required for regulatory data validation before this release can run. - if UpgradeTag.HasUpgradeTag(ValidationTag()) then - exit; - - ValidateAllCustomers(); - UpgradeTag.SetUpgradeTag(ValidationTag()); - end; - - local procedure ValidateAllCustomers() - begin - end; - - local procedure ValidationTag(): Code[250] - begin - exit('MS-000010-ValidateCustomers-20260501'); - end; -} diff --git a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.md b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.md deleted file mode 100644 index 0bfd375..0000000 --- a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [onvalidateupgrade, trigger, upgrade-tag, performance, justification] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Guard performance-impacting upgrade triggers - -## Description - -Upgrade validation triggers such as `OnValidateUpgradePerCompany` can run during upgrade for every tenant and company. Expensive validation, full-table scans, or repair logic in those triggers becomes part of the upgrade's critical path. The trigger is acceptable only when the work is necessary and when re-execution is prevented. - -## Best Practice - -Add written justification for the trigger's work and guard it with an upgrade tag just like a data-migration step. Check `HasUpgradeTag` before the expensive work and call `SetUpgradeTag` only after the work succeeds, so retries do not re-run completed validation. - -See sample: `guard-performance-impacting-upgrade-triggers.good.al`. - -## Anti Pattern - -Putting `ValidateAllCustomers()`, table scans, or external-style setup validation directly in `OnValidateUpgradePerCompany` without a skip tag. The work runs on every upgrade attempt, including retries after unrelated failures. - -See sample: `guard-performance-impacting-upgrade-triggers.bad.al`. diff --git a/microsoft/knowledge/upgrade/hybrid-migration-codeunits-not-standard-upgrade.md b/microsoft/knowledge/upgrade/hybrid-migration-codeunits-not-standard-upgrade.md new file mode 100644 index 0000000..2d047ac --- /dev/null +++ b/microsoft/knowledge/upgrade/hybrid-migration-codeunits-not-standard-upgrade.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [hybrid-migration, hybrid-bc14, hybrid-sl, hybrid-gp, hybrid-base-deployment, one-time-migration] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Hybrid migration codeunits are not standard upgrade codeunits + +## Description + +Codeunits like `HybridBC14`, `HybridSL`, `HybridGP`, and `HybridBaseDeployment` implement one-time migration paths from a specific source system into Business Central. They run in a different pipeline from the standard per-company / per-database upgrade triggers and follow patterns shaped by that source — staging tables, schema-mapped imports, and per-source post-processing. The rules that apply to standard upgrade codeunits — guarded reads, no external calls, `DataTransfer` for bulk init, `Subtype = Upgrade`, upgrade tags — are not the right yardstick for these migration codeunits. + +## Best Practice + +Treat a hybrid migration codeunit as a domain of its own. If you need to add or modify migration logic, follow the conventions of the surrounding hybrid migration codebase (which has its own dispatcher, its own way of recording progress, and its own error handling) rather than imposing standard upgrade conventions on it. Conversely, do not borrow hybrid-migration patterns into standard upgrade codeunits — the platform contract is different. + +When reviewing changes inside a hybrid migration codeunit, do not flag missing upgrade tags, missing `Subtype = Upgrade`, or missing `OnUpgradePerCompany` wiring. None of those apply. + +## Anti Pattern + +Reviewing a change inside `HybridBC14` / `HybridSL` / `HybridGP` / `HybridBaseDeployment` against standard upgrade rules and flagging the absence of `Subtype = Upgrade` or upgrade-tag plumbing. diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.bad.al b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.bad.al deleted file mode 100644 index dd54bbd..0000000 --- a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -tableextension 50812 "Upgrade Sample InitValue Bad" extends Customer -{ - fields - { - field(50101; "Is Active"; Boolean) - { - DataClassification = CustomerContent; - Caption = 'Is active'; - // InitValue applies to new records only. - // Every existing customer remains Is Active = false after the upgrade. - InitValue = true; - } - } -} diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.good.al b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.good.al deleted file mode 100644 index e26cfa3..0000000 --- a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.good.al +++ /dev/null @@ -1,43 +0,0 @@ -tableextension 50810 "Upgrade Sample InitValue Good" extends Customer -{ - fields - { - field(50100; "Is Active"; Boolean) - { - DataClassification = CustomerContent; - Caption = 'Is active'; - InitValue = true; - } - } -} - -codeunit 50811 "Upgrade Sample InitValue Good Upg" -{ - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - begin - UpgradeExistingCustomersIsActive(); - end; - - local procedure UpgradeExistingCustomersIsActive() - var - Customer: Record Customer; - CustomerDataTransfer: DataTransfer; - UpgradeTag: Codeunit "Upgrade Tag"; - begin - if UpgradeTag.HasUpgradeTag(UpgradeCustomerIsActiveTag()) then - exit; - - CustomerDataTransfer.SetTables(Database::Customer, Database::Customer); - CustomerDataTransfer.AddConstantValue(true, Customer.FieldNo("Is Active")); - CustomerDataTransfer.CopyFields(); - - UpgradeTag.SetUpgradeTag(UpgradeCustomerIsActiveTag()); - end; - - local procedure UpgradeCustomerIsActiveTag(): Code[250] - begin - exit('MS-000006-CustomerIsActive-20260501'); - end; -} diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md deleted file mode 100644 index 66669ac..0000000 --- a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [initvalue, field, upgrade, existing-records, migration] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# InitValue on a new field does not populate existing rows - -## Description - -The `InitValue` property sets a field's default for rows created after the field exists. Rows that already exist when the field is added keep the data-type default (empty text, zero, false, epoch date) — InitValue does not retroactively apply. Shipping a new field with `InitValue = true` on an existing table produces a silently inconsistent dataset: new rows match the intended default, existing rows do not, and callers that do not distinguish the two read the wrong state for existing data. - -## Best Practice - -When adding a field to an existing table with a meaningful default, write an upgrade step that populates existing rows with the same value, guarded by its own upgrade tag. Use `DataTransfer` with `AddConstantValue` for set-based initialization (see `use-datatransfer-for-large-dataset-initialization`). Exceptions: brand-new tables; new Boolean fields without InitValue where `false` is the intended existing-row value; new extensions, new feature tables, or setup tables with no meaningful existing data to migrate; and informational fields where empty is an acceptable state. - -See sample: `initvalue-does-not-populate-existing-records.good.al`. - -## Anti Pattern - -Adding `field(100; "Is Active"; Boolean) { InitValue = true; }` to an existing business table without upgrade code. New records are Active; every existing record is silently inactive. The bug surfaces later as "why is this data missing from the default report?" - -See sample: `initvalue-does-not-populate-existing-records.bad.al`. diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.bad.al b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.bad.al new file mode 100644 index 0000000..3ca0ab7 --- /dev/null +++ b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.bad.al @@ -0,0 +1,15 @@ +tableextension 50224 "MyTable Ext" extends "My Table" +{ + fields + { + // InitValue only applies to rows inserted after deployment. + // Pre-existing rows silently carry the datatype default (false). + field(50200; "New Flag"; Boolean) + { + DataClassification = CustomerContent; + Caption = 'New Flag'; + InitValue = true; + } + } + // No accompanying upgrade codeunit to back-fill existing rows. +} diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.good.al b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.good.al new file mode 100644 index 0000000..c61284b --- /dev/null +++ b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.good.al @@ -0,0 +1,43 @@ +tableextension 50222 "MyTable Ext" extends "My Table" +{ + fields + { + field(50200; "New Flag"; Boolean) + { + DataClassification = CustomerContent; + Caption = 'New Flag'; + InitValue = true; + } + } +} + +codeunit 50223 "Upgrade MyTable NewFlag" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyTableNewFlag(); + end; + + local procedure UpgradeMyTableNewFlag() + var + MyTable: Record "My Table"; + UpgradeTag: Codeunit "Upgrade Tag"; + DT: DataTransfer; + begin + if UpgradeTag.HasUpgradeTag(MyTableNewFlagTag()) then + exit; + + DT.SetTables(Database::"My Table", Database::"My Table"); + DT.AddConstantValue(true, MyTable.FieldNo("New Flag")); + DT.CopyFields(); + + UpgradeTag.SetUpgradeTag(MyTableNewFlagTag()); + end; + + local procedure MyTableNewFlagTag(): Code[250] + begin + exit('MS-123456-MyTable-NewFlag-20240101'); + end; +} diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md new file mode 100644 index 0000000..4733ef2 --- /dev/null +++ b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [initvalue, new-field, existing-rows, default-value, table-extension] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `InitValue` does not back-fill existing rows + +## Description + +`InitValue` on a field defines the value the platform assigns when a *new* record is inserted. It does not touch rows that already exist when the field is added. When a new field is added to an existing table — directly or via a table extension — every pre-existing row receives the datatype default (`false` for Boolean, `0` for numeric, empty for text), not the `InitValue`. If the intended semantics require existing rows to carry the `InitValue`, the change is incomplete without an upgrade routine that sets the field on those rows. + +Several legitimate cases do NOT need upgrade code: +- New fields on brand-new tables (no existing rows). +- New `Boolean` fields without `InitValue` where the datatype default `false` is the intended value. +- New fields on configuration / setup tables that have no meaningful "existing data". +- Informational or optional fields (logging, preferences, tracking) where `false` / empty is a valid state. + +## Best Practice + +When a new field on an existing table has an `InitValue` that matters, ship an upgrade procedure that walks the existing rows and sets the field to the same value — typically via `DataTransfer.AddConstantValue` for performance — guarded by an upgrade tag. + +See sample: `initvalue-does-not-update-existing-rows.good.al`. + +## Anti Pattern + +Adding a field with `InitValue = true;` (or any non-default `InitValue`) and shipping no upgrade code. Existing rows silently carry the datatype default, leaving the table in two states: rows created before the upgrade with the wrong value, and rows created after with the right one. + +See sample: `initvalue-does-not-update-existing-rows.bad.al`. diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al new file mode 100644 index 0000000..69994e6 --- /dev/null +++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al @@ -0,0 +1,13 @@ +codeunit 50235 "Upgrade With Validation" +{ + Subtype = Upgrade; + + trigger OnValidateUpgradePerCompany() + begin + // No skip logic and no written justification — full-table validation + // runs on every single upgrade pass. + ValidateAllCustomers(); + end; + + local procedure ValidateAllCustomers() begin end; +} diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al new file mode 100644 index 0000000..9a5a83b --- /dev/null +++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al @@ -0,0 +1,25 @@ +codeunit 50234 "Upgrade With Validation" +{ + Subtype = Upgrade; + + trigger OnValidateUpgradePerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + // Justification: regulatory compliance requires a full-table scan once + // per tenant after this release. Tag prevents re-runs. + if UpgradeTag.HasUpgradeTag(MyValidationUpgradeTag()) then + exit; + + ValidateAllCustomers(); + + UpgradeTag.SetUpgradeTag(MyValidationUpgradeTag()); + end; + + local procedure ValidateAllCustomers() begin end; + + local procedure MyValidationUpgradeTag(): Code[250] + begin + exit('MS-123456-CustomerValidation-20240101'); + end; +} diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md new file mode 100644 index 0000000..2c02def --- /dev/null +++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [on-validate-upgrade-per-company, performance-impact, skip-logic, justification, upgrade-tag] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Performance-impacting upgrade triggers need justification and skip logic + +## Description + +Triggers such as `OnValidateUpgradePerCompany` run on every upgrade pass. When their body performs non-trivial work — full-table scans, cross-table validations — the cost is paid on every upgrade of every tenant, even when there is nothing to validate. That cost is acceptable only when the validation is critical (regulatory compliance, data-integrity guarantees the platform depends on) AND the trigger short-circuits once it has done its work. + +## Best Practice + +A performance-impacting upgrade trigger carries two things: a written comment that names the reason the work has to happen on every upgrade pass, and an early-exit guard backed by an upgrade tag so the work runs at most once per tenant. The `HasUpgradeTag` check at the top exits when the validation has already been recorded; the `SetUpgradeTag` call at the bottom records completion. + +See sample: `minimize-onvalidate-upgrade-triggers.good.al`. + +## Anti Pattern + +Doing real work in `OnValidateUpgradePerCompany` with no upgrade-tag guard. The same scan runs every upgrade, multiplying upgrade time by the number of releases the customer takes. + +See sample: `minimize-onvalidate-upgrade-triggers.bad.al`. diff --git a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.bad.al b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.bad.al new file mode 100644 index 0000000..2827b96 --- /dev/null +++ b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.bad.al @@ -0,0 +1,13 @@ +codeunit 50215 "Upgrade No External" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + Client: HttpClient; + Response: HttpResponseMessage; + begin + // External call inside upgrade code — can hang or fail and abort the upgrade. + Client.Get('https://external-service.contoso.com/api/sync', Response); + end; +} diff --git a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.good.al b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.good.al new file mode 100644 index 0000000..48f62b1 --- /dev/null +++ b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.good.al @@ -0,0 +1,17 @@ +codeunit 50214 "Upgrade No External" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + ExternalSyncSetup: Record "External Sync Setup"; + begin + // Defer the external call: just set a flag the runtime path will pick up. + if not ExternalSyncSetup.Get() then begin + ExternalSyncSetup.Init(); + ExternalSyncSetup.Insert(); + end; + ExternalSyncSetup."Resync Required" := true; + ExternalSyncSetup.Modify(); + end; +} diff --git a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md new file mode 100644 index 0000000..eb644b6 --- /dev/null +++ b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [httpclient, dotnet, external-service, network-call, blocking, upgrade-rollback] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# No external calls inside upgrade codeunits + +## Description + +Upgrade code runs in a constrained execution window: the tenant is mid-upgrade, no users are signed in, and a failure aborts the entire transaction. An external HTTP call, DotNet interop call, or any other I/O to a system outside Business Central can hang or fail for reasons completely unrelated to the upgrade — DNS, expired credentials, a service that is itself being upgraded — and the upgrade fails with it. Rolling back from such a failure is hard because the upgrade pipeline assumes its work is deterministic. + +The rule applies inside any codeunit with `Subtype = Upgrade` and to any procedure transitively invoked from `OnUpgrade...` triggers. The same calls in regular runtime code — pages, table triggers, normal codeunits, background jobs — are fine. + +## Best Practice + +Defer external calls to runtime code. If a piece of upgrade work conceptually needs data from an external service, set a flag or write a queue row during upgrade and have the runtime code make the call later (for example on first user sign-in or via job queue), where retries and degraded modes are tractable. + +See sample: `no-external-calls-in-upgrade.good.al`. + +## Anti Pattern + +Calling `HttpClient.Get`, `HttpClient.Post`, or DotNet interop methods from `OnUpgradePerCompany`, `OnUpgradePerDatabase`, or any procedure they invoke. + +See sample: `no-external-calls-in-upgrade.bad.al`. diff --git a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.bad.al b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.bad.al new file mode 100644 index 0000000..e28ae94 --- /dev/null +++ b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.bad.al @@ -0,0 +1,14 @@ +// Skipping the Pending stage and going straight to Removed leaves callers +// and persisted rows with no migration window. +enum 50231 "My Enum" +{ + Extensible = true; + value(0; "First") { } + value(1; "Second") + { + ObsoleteState = Removed; + ObsoleteReason = 'Replaced by NewValue'; + ObsoleteTag = '22.0'; + } + value(2; "Third") { } +} diff --git a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.good.al b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.good.al new file mode 100644 index 0000000..be94807 --- /dev/null +++ b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.good.al @@ -0,0 +1,29 @@ +// Release N: deprecation announced. +enum 50229 "My Enum N" +{ + Extensible = true; + value(0; "First") { } + value(1; "Second") + { + ObsoleteState = Pending; + ObsoleteReason = 'Replaced by NewValue'; + ObsoleteTag = '22.0'; + } + value(2; "Third") { } + value(3; "NewValue") { } +} + +// Release N+1 (or later): removal staged; upgrade code now migrates persisted rows. +enum 50230 "My Enum NPlus1" +{ + Extensible = true; + value(0; "First") { } + value(1; "Second") + { + ObsoleteState = Removed; + ObsoleteReason = 'Replaced by NewValue'; + ObsoleteTag = '22.0'; + } + value(2; "Third") { } + value(3; "NewValue") { } +} diff --git a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md new file mode 100644 index 0000000..cb008ac --- /dev/null +++ b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [obsolete-state, pending, removed, lifecycle, clean-flag, upgrade-code-timing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Stage obsoletion `Pending → Removed`; write upgrade code on removal + +## Description + +`ObsoleteState` has a deliberate two-step lifecycle. `Pending` keeps the element compilable and present — callers still find it but receive a deprecation warning. `Removed` marks the element as gone from the contract; the body may be empty or wrapped in `#if not CLEAN` so the symbol survives only for binary compatibility. Upgrade code that migrates persisted data away from the obsolete element is normally written when the element moves to `Removed`, not when it goes `Pending`. `ObsoleteState = Pending` without accompanying upgrade code is the expected steady state during the deprecation window; reviewers should not flag that combination as missing migration. + +## Best Practice + +Stage the deprecation across releases. Step 1: mark `Pending` with reason and tag; consumers are warned but data and code keep working. Step 2: in a later release, transition to `Removed` and (if persisted data references the element) ship an upgrade procedure that migrates that data — gated by an upgrade tag. The standard mechanic for retiring the actual implementation body is to remove the `#if not CLEAN` block in the same release that flips the state to `Removed`. + +See sample: `obsolete-pending-to-removed-staging.good.al`. + +## Anti Pattern + +Jumping straight to `ObsoleteState = Removed` without a prior `Pending` release. Consumers have no deprecation window to migrate and any data still referencing the element is stranded. Equally wrong: leaving an element `Pending` indefinitely and never staging its removal — the deprecation never completes. + +See sample: `obsolete-pending-to-removed-staging.bad.al`. diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al new file mode 100644 index 0000000..22290a4 --- /dev/null +++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al @@ -0,0 +1,8 @@ +codeunit 50228 "Old Method Holder" +{ + // ObsoleteState set without ObsoleteReason or ObsoleteTag. + [Obsolete('')] + procedure OldMethod() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al new file mode 100644 index 0000000..8562b0c --- /dev/null +++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al @@ -0,0 +1,12 @@ +codeunit 50227 "Old Method Holder" +{ + [Obsolete('Use NewMethod instead for better performance', '22.0')] + procedure OldMethod() + begin + // Body kept while ObsoleteState = Pending; warns at call sites. + end; + + procedure NewMethod() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md new file mode 100644 index 0000000..0f2e11e --- /dev/null +++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md @@ -0,0 +1,36 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [obsolete-state, obsolete-reason, obsolete-tag, deprecation, metadata] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Mark obsolete elements with `ObsoleteState`, `ObsoleteReason`, and `ObsoleteTag` + +## Description + +When a procedure, field, table, page, or enum value is being retired, AL requires three pieces of metadata to declare the deprecation: + +- `ObsoleteState` — `Pending` while the element still exists but is being phased out, `Removed` once it should no longer be used. +- `ObsoleteReason` — a short human-readable string explaining what to use instead. Tooling and downstream consumers surface this when warning callers. +- `ObsoleteTag` — a stable version-like marker (typically the release version in which the deprecation was introduced, e.g. `'22.0'`). + +Omitting `ObsoleteReason` or `ObsoleteTag` leaves consumers with `ObsoleteState = Pending` but no guidance and no traceability. Declaring `ObsoleteState = Removed` without a reason or tag is the same failure with a stronger blast radius. + +## Best Practice + +Every obsoleted element carries all three properties together. The reason names the replacement explicitly; the tag is the version in which the deprecation was introduced and stays stable for the life of the deprecation. + +See sample: `obsoletion-requires-reason-and-tag.good.al`. + +## Anti Pattern + +Setting only `ObsoleteState = Pending;` (or `Removed`) without `ObsoleteReason` and `ObsoleteTag`. Callers see a warning with no explanation, and the deprecation cannot be tracked by version. + +See sample: `obsoletion-requires-reason-and-tag.bad.al`. + +## See also + +- `obsolete-pending-to-removed-staging.md` — when to advance `Pending` to `Removed` and write upgrade code. diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al index d2ebe49..adf5cf5 100644 --- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al @@ -1,4 +1,4 @@ -codeunit 50805 "Upgrade Sample TagRegister Bad" +codeunit 50213 "Upgrade Tag Registration" { Subtype = Upgrade; @@ -6,17 +6,15 @@ codeunit 50805 "Upgrade Sample TagRegister Bad" var UpgradeTag: Codeunit "Upgrade Tag"; begin - if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then + if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then exit; - - UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag()); + UpgradeTag.SetUpgradeTag(MyUpgradeTag()); end; - // Missing OnGetPerCompanyUpgradeTags subscriber. - // The tag is set but the platform's upgrade-tag machinery does not know about it. - - local procedure FeatureXUpgradeTag(): Code[250] + local procedure MyUpgradeTag(): Code[250] begin - exit('MS-000004-FeatureX-20260501'); + exit('MS-123456-MyFeature-20240101'); end; + + // No OnGetPerCompanyUpgradeTags subscriber — the tag is unknown to the platform. } diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al index 0fb5118..02362c9 100644 --- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al @@ -1,4 +1,4 @@ -codeunit 50804 "Upgrade Sample TagRegister Good" +codeunit 50212 "Upgrade Tag Registration" { Subtype = Upgrade; @@ -6,20 +6,20 @@ codeunit 50804 "Upgrade Sample TagRegister Good" var UpgradeTag: Codeunit "Upgrade Tag"; begin - if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then + if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then exit; + // Upgrade work ... + UpgradeTag.SetUpgradeTag(MyUpgradeTag()); + end; - UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag()); + local procedure MyUpgradeTag(): Code[250] + begin + exit('MS-123456-MyFeature-20240101'); end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Upgrade Tag", 'OnGetPerCompanyUpgradeTags', '', false, false)] local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]]) begin - PerCompanyUpgradeTags.Add(FeatureXUpgradeTag()); - end; - - local procedure FeatureXUpgradeTag(): Code[250] - begin - exit('MS-000003-FeatureX-20260501'); + PerCompanyUpgradeTags.Add(MyUpgradeTag()); end; } diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md index 9f34007..a413520 100644 --- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md @@ -1,26 +1,28 @@ --- bc-version: [all] domain: upgrade -keywords: [upgrade-tag, ongetpercompanyupgradetags, ongetperdatabaseupgradetags, registration] +keywords: [upgrade-tag, event-subscriber, on-get-per-company-upgrade-tags, on-get-per-database-upgrade-tags, registration] technologies: [al] countries: [w1] application-area: [all] --- -# Register every upgrade tag with the matching PerCompany or PerDatabase subscriber +# Register every upgrade tag with the platform via an event subscriber ## Description -An upgrade tag set via `UpgradeTag.SetUpgradeTag` only participates in the platform's upgrade-tag machinery when it is also registered through `OnGetPerCompanyUpgradeTags` or `OnGetPerDatabaseUpgradeTags` event subscribers on `Codeunit "Upgrade Tag"`. Without registration, the platform cannot enumerate the tag for diagnostic reporting, skipped-step detection, or cross-app coordination. The step still runs and sets the tag, but the tag is effectively invisible to the rest of the upgrade infrastructure. +The `Upgrade Tag` codeunit only recognizes a tag if the tag was published to the platform through one of two events on that codeunit: `OnGetPerCompanyUpgradeTags` for tags set inside `OnUpgradePerCompany`, and `OnGetPerDatabaseUpgradeTags` for tags set inside `OnUpgradePerDatabase`. A tag that is `Set` and `Has`-checked in code but never added to one of these lists is unknown to the platform — its semantics around skip-on-reinstall, telemetry, and operator queries do not apply. + +The registration scope must match where the tag is set: a tag used from `OnUpgradePerCompany` registers in `OnGetPerCompanyUpgradeTags`; a tag used from `OnUpgradePerDatabase` registers in `OnGetPerDatabaseUpgradeTags`. Crossing the scopes silently breaks the tag. ## Best Practice -For every upgrade-tag constant referenced in `HasUpgradeTag`/`SetUpgradeTag`, register it in the subscriber that matches its trigger scope: tags used from `OnUpgradePerCompany` go in `OnGetPerCompanyUpgradeTags`; tags used from `OnUpgradePerDatabase` go in `OnGetPerDatabaseUpgradeTags`. Treat this mapping as a review point, not just a naming convention. Keep the tag string in a single source (Label or function) and reference it at the guard, the setter, and the registration. +For every new upgrade tag, add one line to the matching subscriber: `PerCompanyUpgradeTags.Add(MyUpgradeTag());` or `PerDatabaseUpgradeTags.Add(MyUpgradeTag());`. Place the subscribers in the same codeunit (or a dedicated "Upgrade Tag Definitions" codeunit) so the tag string and its registration stay together. See sample: `register-upgrade-tags-with-subscribers.good.al`. ## Anti Pattern -Adding a new `UpgradeTag.SetUpgradeTag(MyTag())` without the matching `PerCompanyUpgradeTags.Add(MyTag())` in the registration subscriber, or registering a tag used from `OnUpgradePerCompany` in `OnGetPerDatabaseUpgradeTags`. The code compiles and the step completes, but the tag is invisible or registered at the wrong scope. +Calling `UpgradeTag.SetUpgradeTag(MyUpgradeTag())` without ever adding `MyUpgradeTag()` to the corresponding `OnGetPerCompany...` / `OnGetPerDatabase...` subscriber. See sample: `register-upgrade-tags-with-subscribers.bad.al`. diff --git a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.bad.al b/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.bad.al deleted file mode 100644 index 300b5d7..0000000 --- a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50820 "Upgrade Sample SkipContext Bad" -{ - procedure AddReportSelectionEntries() - begin - // No execution-context check. On upgrade, this either throws on - // primary-key conflict or silently overwrites the tenant's - // customized report selections. - InsertDefaultReportSelections(); - end; - - local procedure InsertDefaultReportSelections() - begin - end; -} diff --git a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.good.al b/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.good.al deleted file mode 100644 index 04ade74..0000000 --- a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.good.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50819 "Upgrade Sample SkipContext Good" -{ - procedure AddReportSelectionEntries() - begin - // Existing tenants already have the selections, possibly customized. - if GetExecutionContext() = ExecutionContext::Upgrade then - exit; - - InsertDefaultReportSelections(); - end; - - local procedure InsertDefaultReportSelections() - begin - end; -} diff --git a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.md b/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.md deleted file mode 100644 index 39a53df..0000000 --- a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [executioncontext, upgrade, reportselections, initialization, install] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Skip non-essential initialization when ExecutionContext is Upgrade - -## Description - -Initialization code that inserts default rows — report selections, number-series, setup-table defaults — is correct on first install and harmful during upgrade. Existing tenants already have these rows, possibly customized; re-running the initialization either fails on primary-key conflicts or silently overwrites customer configuration. The platform exposes `GetExecutionContext()` so the same procedure can be safely called from install and upgrade paths without duplicating the insert logic. - -## Best Practice - -Check `if GetExecutionContext() = ExecutionContext::Upgrade then exit;` at the top of idempotent-on-install-only procedures. Keep the early exit narrow and document the reason. The check should be additive to existing guards, not a replacement for proper primary-key handling in the insert itself. - -See sample: `skip-non-essential-work-during-upgrade-context.good.al`. - -## Anti Pattern - -A procedure that unconditionally inserts a default report-selection, number-series, or setup row, called from both install and upgrade paths. On upgrade it either throws on the conflicting key or overwrites the tenant's existing configuration. - -See sample: `skip-non-essential-work-during-upgrade-context.bad.al`. diff --git a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.bad.al b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.bad.al new file mode 100644 index 0000000..db0df6f --- /dev/null +++ b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.bad.al @@ -0,0 +1,12 @@ +codeunit 50217 "Report Selection Seeder" +{ + procedure AddReportSelectionEntries() + var + ReportSelections: Record "Report Selections"; + begin + // No context check — fires during upgrade and silently inserts rows + // the upgrade pipeline never asked for. + ReportSelections.Init(); + ReportSelections.Insert(); + end; +} diff --git a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.good.al b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.good.al new file mode 100644 index 0000000..61dfeda --- /dev/null +++ b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.good.al @@ -0,0 +1,15 @@ +codeunit 50216 "Report Selection Seeder" +{ + procedure AddReportSelectionEntries() + var + ReportSelections: Record "Report Selections"; + begin + // Do not add report-selection entries during upgrade; the upgrade pipeline + // does not need them and re-running this on every upgrade is wasteful. + if GetExecutionContext() = ExecutionContext::Upgrade then + exit; + + ReportSelections.Init(); + ReportSelections.Insert(); + end; +} diff --git a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md new file mode 100644 index 0000000..0b441e6 --- /dev/null +++ b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [get-execution-context, execution-context-upgrade, skip, report-selection, runtime-trigger] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Skip non-essential runtime work when `GetExecutionContext() = ExecutionContext::Upgrade` + +## Description + +Runtime procedures (table triggers, install routines, helpers called from many places) sometimes fire during the upgrade window because the upgrade itself touches the data they react to. When the work those procedures do is not strictly required for the upgrade to succeed — inserting report-selection entries, seeding optional configuration, sending welcome notifications — they should detect upgrade context with `GetExecutionContext() = ExecutionContext::Upgrade` and exit. This keeps upgrade transactions tight and avoids side effects that the upgrade pipeline did not ask for. + +This is the opposite of a load-bearing concern: code that MUST run during the upgrade does not consult execution context. The check is for *optional* side effects that happen to be wired into runtime code paths. + +## Best Practice + +In a runtime procedure that performs non-essential side effects, guard the side-effect block with `if GetExecutionContext() = ExecutionContext::Upgrade then exit;` and include a brief comment explaining what is being skipped and why. + +See sample: `skip-nonessential-work-via-execution-context.good.al`. + +## Anti Pattern + +Using `GetExecutionContext()` to *enable* upgrade behaviour from outside an upgrade codeunit. Upgrade behaviour belongs in a codeunit with `Subtype = Upgrade`; runtime code should only use the check to *suppress* optional work. + +See sample: `skip-nonessential-work-via-execution-context.bad.al`. diff --git a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.bad.al b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.bad.al new file mode 100644 index 0000000..e409ea8 --- /dev/null +++ b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.bad.al @@ -0,0 +1,12 @@ +codeunit 50203 "Upgrade Orchestrator" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + Customer: Record Customer; + begin + // Direct implementation in the trigger body — wrong. + Customer.ModifyAll("Some Field", true); + end; +} diff --git a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.good.al b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.good.al new file mode 100644 index 0000000..d03fa4a --- /dev/null +++ b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.good.al @@ -0,0 +1,19 @@ +codeunit 50202 "Upgrade Orchestrator" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + UpgradeSecondFeature(); + end; + + local procedure UpgradeMyFeature() + var + Customer: Record Customer; + begin + Customer.ModifyAll("Some Field", true); + end; + + local procedure UpgradeSecondFeature() begin end; +} diff --git a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md new file mode 100644 index 0000000..dcc21e3 --- /dev/null +++ b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [on-upgrade-per-company, on-upgrade-per-database, trigger-body, helper-procedure, structure] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `OnUpgradePerCompany` / `OnUpgradePerDatabase` should call helpers, not inline logic + +## Description + +The `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on an upgrade codeunit are dispatch points, not implementation slots. They should contain only calls to named local procedures — one call per feature being upgraded. Putting `ModifyAll`, record loops, or any business logic directly inside the trigger body makes the upgrade impossible to read, impossible to selectively skip via upgrade tags per feature, and impossible to extend without touching the trigger itself. + +Empty `OnUpgradePerCompany` / `OnUpgradePerDatabase` triggers are acceptable — they may be placeholders for future use or artifacts from cleanup. + +## Best Practice + +Each upgrade trigger contains an ordered list of procedure calls, one per feature: `UpgradeFeatureA();` `UpgradeFeatureB();`. Each procedure handles its own upgrade tag, its own data work, and can be added or removed independently. + +See sample: `triggers-call-helpers-not-implementations.good.al`. + +## Anti Pattern + +Implementing record loops, `ModifyAll`, or other data work directly in the trigger body. The trigger then mixes orchestration with implementation, and adding a second feature requires editing the trigger rather than appending one line. + +See sample: `triggers-call-helpers-not-implementations.bad.al`. diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al new file mode 100644 index 0000000..024443c --- /dev/null +++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al @@ -0,0 +1,10 @@ +codeunit 50201 "Upgrade My Feature" +{ + // Missing Subtype = Upgrade; the OnUpgrade trigger is never dispatched. + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + local procedure UpgradeMyFeature() begin end; +} diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al new file mode 100644 index 0000000..5ef4e88 --- /dev/null +++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al @@ -0,0 +1,17 @@ +codeunit 50200 "Upgrade My Feature" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + trigger OnUpgradePerDatabase() + begin + UpgradeMyGlobalSetup(); + end; + + local procedure UpgradeMyFeature() begin end; + local procedure UpgradeMyGlobalSetup() begin end; +} diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md new file mode 100644 index 0000000..2dba21a --- /dev/null +++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [upgrade-codeunit, subtype, on-upgrade-per-company, on-upgrade-per-database, trigger] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Upgrade logic must live in a codeunit with `Subtype = Upgrade` + +## Description + +A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upgrade`. The platform then dispatches the `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on that codeunit during upgrade. A codeunit without `Subtype = Upgrade` — even one that declares an `OnUpgradePerCompany` trigger — is not an upgrade codeunit, and reviewers ignore it for upgrade concerns. Conversely, any procedure invoked transitively from an `OnUpgrade...` trigger of an upgrade codeunit IS upgrade code regardless of where it lives, and the upgrade rules apply to it. + +## Best Practice + +Place every piece of upgrade logic in a codeunit declared with `Subtype = Upgrade;` and expose entry points via the two triggers `OnUpgradePerCompany` and `OnUpgradePerDatabase`. Helper procedures may live in normal codeunits, but they inherit the upgrade-context rules (guarded reads, no external calls, upgrade tags, etc.) when called from an upgrade trigger. + +See sample: `upgrade-codeunit-subtype.good.al`. + +## Anti Pattern + +Putting upgrade-style logic in a regular codeunit that the platform never invokes during upgrade — for example a normal codeunit with a manually invented "RunUpgrade" procedure that nothing wires to the upgrade pipeline. The migration code will simply not run. + +See sample: `upgrade-codeunit-subtype.bad.al`. diff --git a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.bad.al b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.bad.al deleted file mode 100644 index c232adb..0000000 --- a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.bad.al +++ /dev/null @@ -1,22 +0,0 @@ -codeunit 50809 "Upgrade Sample DataTransfer Bad" -{ - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - begin - InitializeNewFlag(); - end; - - local procedure InitializeNewFlag() - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - // Row-at-a-time update over a 10M-row ledger table. Multi-hour upgrade. - CustLedgerEntry.SetRange(Open, true); - if CustLedgerEntry.FindSet(true) then - repeat - CustLedgerEntry."New Flag" := false; - CustLedgerEntry.Modify(); - until CustLedgerEntry.Next() = 0; - end; -} diff --git a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.good.al b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.good.al deleted file mode 100644 index b45a9b9..0000000 --- a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.good.al +++ /dev/null @@ -1,31 +0,0 @@ -codeunit 50808 "Upgrade Sample DataTransfer Good" -{ - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - begin - InitializeNewFlag(); - end; - - local procedure InitializeNewFlag() - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - CLEDataTransfer: DataTransfer; - UpgradeTag: Codeunit "Upgrade Tag"; - begin - if UpgradeTag.HasUpgradeTag(InitializeNewFlagTag()) then - exit; - - CLEDataTransfer.SetTables(Database::"Cust. Ledger Entry", Database::"Cust. Ledger Entry"); - CLEDataTransfer.AddSourceFilter(CustLedgerEntry.FieldNo(Open), '=%1', true); - CLEDataTransfer.AddConstantValue(false, CustLedgerEntry.FieldNo("New Flag")); - CLEDataTransfer.CopyFields(); - - UpgradeTag.SetUpgradeTag(InitializeNewFlagTag()); - end; - - local procedure InitializeNewFlagTag(): Code[250] - begin - exit('MS-000005-CLEInitializeNewFlag-20260501'); - end; -} diff --git a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md deleted file mode 100644 index 96d7526..0000000 --- a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [datatransfer, initvalue, large-dataset, bulk-update, upgrade] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use DataTransfer to initialize large tables in upgrade; not FindSet plus Modify - -## Description - -An upgrade that populates a new field on existing rows with a FindSet+Modify loop pays a round-trip and a per-row trigger invocation for every row — turning a multi-hour upgrade into a multi-day one on ledger-entry-scale tables. `DataTransfer` pushes the update to SQL as a single set-based operation using source filters and constant values, which is the supported platform mechanism for this scenario. The tradeoff: DataTransfer bypasses validation triggers and event subscribers — if the step depends on trigger logic, that has to be reconstructed explicitly. - -## Best Practice - -Use DataTransfer when a new field added to an existing table needs initialization across existing rows, and for any table that can contain more than 300,000 records. Tables in the ledger-entry and document-line category reliably exceed this threshold; treat them as requiring DataTransfer by default. - -Set tables, add source filters, add constant values, call CopyFields, clear, and repeat for additional slices. Use the pattern for new fields and tables added in the same change. If no new field or table is involved, document why validation triggers and event subscribers are safe to bypass, or keep the explicit loop that invokes the business logic. - -See sample: `use-datatransfer-for-large-dataset-initialization.good.al`. - -## Anti Pattern - -`FindSet(true)` + `Modify()` in a loop as the initialization path for a new field across an entire existing table. The resulting upgrade time is proportional to the row count; for a ten-million-row ledger-entry table it is the single largest step in the release. - -See sample: `use-datatransfer-for-large-dataset-initialization.bad.al`. diff --git a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.bad.al b/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.bad.al deleted file mode 100644 index 3ec4385..0000000 --- a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50818 "Upgrade Sample Obsolete Bad" -{ - // Straight to Removed with no preceding Pending phase, no ObsoleteReason, - // no ObsoleteTag. Dependents compiled against the previous release hit - // a hard compile error with no migration signal. - [Obsolete('', '')] - procedure CalculateNetAmount(Amount: Decimal): Decimal - begin - Error('Removed.'); - end; -} diff --git a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.good.al b/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.good.al deleted file mode 100644 index 2dc1f9c..0000000 --- a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.good.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50817 "Upgrade Sample Obsolete Good" -{ - [Obsolete('Use CalculateNetAmountV2 for the updated rounding semantics.', '28.0')] - procedure CalculateNetAmount(Amount: Decimal): Decimal - begin - exit(Amount); - end; - - procedure CalculateNetAmountV2(Amount: Decimal): Decimal - begin - exit(Amount); - end; -} diff --git a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.md b/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.md deleted file mode 100644 index 0c5a4e8..0000000 --- a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [obsolete, obsoletestate, obsoletereason, obsoletetag, deprecation] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Deprecate via ObsoleteState Pending first; move to Removed only after the grace window - -## Description - -AL's obsolete workflow is two-stage by design. `ObsoleteState = Pending` keeps the object or member compilable and callable but emits warnings and records the deprecation in metadata. `ObsoleteState = Removed` makes it a compile error for callers. Jumping straight to Removed — or marking Pending without `ObsoleteReason` and `ObsoleteTag` — breaks dependents who had no signal to migrate, and loses the tooling's ability to surface the planned removal in sandbox builds before the production tenant upgrades. - -## Best Practice - -Mark the element `ObsoleteState = Pending` with a concrete `ObsoleteReason` naming the replacement and an `ObsoleteTag` identifying the version the deprecation started. Keep it Pending through at least one major release so dependents have a cycle to migrate. Move to `ObsoleteState = Removed` only in a later release, with the same Reason and Tag retained or updated. - -See sample: `use-obsolete-pending-before-removed.good.al`. - -## Anti Pattern - -`[Obsolete('', '')]` or `ObsoleteState = Removed` applied directly on an element that was public and callable in the previous release, with no preceding Pending phase. Dependents get a hard compile error with no migration signal in the previous version. - -See sample: `use-obsolete-pending-before-removed.bad.al`. diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al index bccfb66..f16946e 100644 --- a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al @@ -1,4 +1,4 @@ -codeunit 50803 "Upgrade Sample TagGuard Bad" +codeunit 50209 "Upgrade Tag Driven" { Subtype = Upgrade; @@ -8,20 +8,18 @@ codeunit 50803 "Upgrade Sample TagGuard Bad" begin NavApp.GetCurrentModuleInfo(AppInfo); - // Version check: fragile across skipped versions, and every nested branch - // is another place a customer can be stuck if the matching step fails. - if AppInfo.DataVersion().Major < 18 then + // Version-coupled branching — breaks when a tenant skips a version. + if AppInfo.DataVersion().Major > 14 then + exit; + + if AppInfo.DataVersion().Major < 14 then UpgradeFeatureA() + else if AppInfo.DataVersion().Major < 17 then + UpgradeFeatureB() else - if AppInfo.DataVersion().Major < 21 then - UpgradeFeatureB(); + exit; end; - local procedure UpgradeFeatureA() - begin - end; - - local procedure UpgradeFeatureB() - begin - end; + local procedure UpgradeFeatureA() begin end; + local procedure UpgradeFeatureB() begin end; } diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al index 4c90219..958e3b6 100644 --- a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al @@ -1,26 +1,26 @@ -codeunit 50802 "Upgrade Sample TagGuard Good" +codeunit 50208 "Upgrade Tag Driven" { Subtype = Upgrade; trigger OnUpgradePerCompany() begin - UpgradeFeatureX(); + UpgradeMyFeature(); end; - local procedure UpgradeFeatureX() + local procedure UpgradeMyFeature() var UpgradeTag: Codeunit "Upgrade Tag"; begin - if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then + if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then exit; - // Idempotent, retries cleanly after failure, runs exactly once. + // Upgrade work goes here. - UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag()); + UpgradeTag.SetUpgradeTag(MyUpgradeTag()); end; - local procedure FeatureXUpgradeTag(): Code[250] + local procedure MyUpgradeTag(): Code[250] begin - exit('MS-000002-FeatureX-20260501'); + exit('MS-123456-MyFeatureUpgrade-20240101'); end; } diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md index 86f82bb..62347d1 100644 --- a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md @@ -1,26 +1,31 @@ --- bc-version: [all] domain: upgrade -keywords: [upgrade-tag, dataversion, version-check, idempotent, guard] +keywords: [upgrade-tag, version-check, dataversion, has-upgrade-tag, set-upgrade-tag, control-flow] technologies: [al] countries: [w1] application-area: [all] --- -# Guard upgrade steps with upgrade tags, not version checks +# Control upgrade execution with upgrade tags, not version checks ## Description -`DataVersion()` comparisons tie an upgrade step to a specific release cadence: if the step is skipped or fails on one version and the tenant upgrades past the check before the step succeeds, the step never runs. Upgrade tags, managed by `Codeunit "Upgrade Tag"`, record per-step completion in the tenant database. A tag-guarded step runs once, retries cleanly after failure, and remains idempotent across future versions regardless of the version the customer is upgrading from. +Each piece of upgrade logic must run exactly once per company (or database) across the lifetime of an extension. The platform mechanism for that is the `Upgrade Tag` codeunit: a procedure asks `HasUpgradeTag(MyTag())` at entry, performs its work, then calls `SetUpgradeTag(MyTag())` to record completion. Subsequent upgrades on the same tenant see the tag and skip the work. Hand-rolled `if MyApp.DataVersion().Major < N then ...` chains are the wrong tool: they are version-coupled, accumulate stale branches over time, and break when a tenant skips a version. ## Best Practice -Guard each standard upgrade step with `if UpgradeTag.HasUpgradeTag(MyTag()) then exit;` at the top of the procedure. After the step completes, call `UpgradeTag.SetUpgradeTag(MyTag())`. Define the tag string in a `Tok`-suffixed Label or returning function so the same constant is referenced at both the guard and the registration (see `register-upgrade-tags-with-getpercompany-getperdatabase-subscribers`). The supported DataVersion exception is first-install detection in `OnInstallAppPerCompany` with the `0.0.0.0` sentinel; one-time Hybrid migration codeunits follow separate migration patterns and should not be forced into ordinary upgrade-tag structure. +Every upgrade procedure starts with a `HasUpgradeTag` guard and ends with `SetUpgradeTag` once the work is committed. Each feature gets its own tag string so features can be re-run independently if needed. See sample: `use-upgrade-tags-not-version-checks.good.al`. ## Anti Pattern -`if MyApp.DataVersion().Major < 18 then UpgradeFeatureA();` inside a standard upgrade step — the step runs on every upgrade from a pre-18 version, may fail on partial data, and the next retry re-runs work that already succeeded. Nesting version-check branches (`< 14` → step A, `< 17` → step B) compounds the fragility. +Branching on `MyApp.DataVersion().Major > N`, or chains of `< N` / `< M` to decide which upgrade step to run. Such code becomes unmaintainable after a few releases and silently does the wrong thing on tenants that skip versions. See sample: `use-upgrade-tags-not-version-checks.bad.al`. + +## See also + +- `first-install-dataversion-zero-check.md` — the one situation where reading `DataVersion()` is the right call. +- `register-upgrade-tags-with-subscribers.md` — how to make a tag known to the platform.