From e2b7d534f9120f5c79af65e584d2174adf587577 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 25 Jun 2026 14:04:14 +0200 Subject: [PATCH 01/86] Promote events knowledge from community to Microsoft layer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../events/avoid-raising-events-inside-try-functions.bad.al | 0 .../events/avoid-raising-events-inside-try-functions.good.al | 0 .../knowledge/events/avoid-raising-events-inside-try-functions.md | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename {community => microsoft}/knowledge/events/avoid-raising-events-inside-try-functions.bad.al (100%) rename {community => microsoft}/knowledge/events/avoid-raising-events-inside-try-functions.good.al (100%) rename {community => microsoft}/knowledge/events/avoid-raising-events-inside-try-functions.md (100%) diff --git a/community/knowledge/events/avoid-raising-events-inside-try-functions.bad.al b/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.bad.al similarity index 100% rename from community/knowledge/events/avoid-raising-events-inside-try-functions.bad.al rename to microsoft/knowledge/events/avoid-raising-events-inside-try-functions.bad.al diff --git a/community/knowledge/events/avoid-raising-events-inside-try-functions.good.al b/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.good.al similarity index 100% rename from community/knowledge/events/avoid-raising-events-inside-try-functions.good.al rename to microsoft/knowledge/events/avoid-raising-events-inside-try-functions.good.al diff --git a/community/knowledge/events/avoid-raising-events-inside-try-functions.md b/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.md similarity index 100% rename from community/knowledge/events/avoid-raising-events-inside-try-functions.md rename to microsoft/knowledge/events/avoid-raising-events-inside-try-functions.md From ee5cedee403d863fe90a8852397480dead9540ed Mon Sep 17 00:00:00 2001 From: Dmitry Katson Date: Fri, 26 Jun 2026 03:13:52 -0400 Subject: [PATCH 02/86] community(ui): SetSelectionFilter on list pages silently scopes to cursor row when no explicit selection Co-authored-by: Cursor --- .../ui/set-selection-filter-list-scope.bad.al | 12 +++++++++ .../set-selection-filter-list-scope.good.al | 14 ++++++++++ .../ui/set-selection-filter-list-scope.md | 26 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 community/knowledge/ui/set-selection-filter-list-scope.bad.al create mode 100644 community/knowledge/ui/set-selection-filter-list-scope.good.al create mode 100644 community/knowledge/ui/set-selection-filter-list-scope.md diff --git a/community/knowledge/ui/set-selection-filter-list-scope.bad.al b/community/knowledge/ui/set-selection-filter-list-scope.bad.al new file mode 100644 index 0000000..ff3ca99 --- /dev/null +++ b/community/knowledge/ui/set-selection-filter-list-scope.bad.al @@ -0,0 +1,12 @@ +// Bad: SetSelectionFilter with cursor-only (no explicit multi-selection) produces +// a primary key filter for just that one row. The codeunit receives only that row; +// the rest of the visible list is silently skipped with no error raised. +trigger OnAction() +var + PriceListHeader: Record "Price List Header"; + TempErrorMessage: Record "Error Message" temporary; + ProcessingCodeunit: Codeunit "My Batch Processor"; +begin + CurrPage.SetSelectionFilter(PriceListHeader); + ProcessingCodeunit.RunBatch(PriceListHeader, TempErrorMessage); +end; diff --git a/community/knowledge/ui/set-selection-filter-list-scope.good.al b/community/knowledge/ui/set-selection-filter-list-scope.good.al new file mode 100644 index 0000000..e3a91af --- /dev/null +++ b/community/knowledge/ui/set-selection-filter-list-scope.good.al @@ -0,0 +1,14 @@ +// Good: check MarkedOnly before deciding which scope to process. +// When MarkedOnly is false (cursor-only or Ctrl+A) fall back to Copy(Rec) +// so every record visible in the page view is included. +trigger OnAction() +var + PriceListHeader: Record "Price List Header"; + TempErrorMessage: Record "Error Message" temporary; + ProcessingCodeunit: Codeunit "My Batch Processor"; +begin + CurrPage.SetSelectionFilter(PriceListHeader); + if not PriceListHeader.MarkedOnly then + PriceListHeader.Copy(Rec); + ProcessingCodeunit.RunBatch(PriceListHeader, TempErrorMessage); +end; diff --git a/community/knowledge/ui/set-selection-filter-list-scope.md b/community/knowledge/ui/set-selection-filter-list-scope.md new file mode 100644 index 0000000..59bde40 --- /dev/null +++ b/community/knowledge/ui/set-selection-filter-list-scope.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: ui +keywords: [set-selection-filter, marked-only, list-page, bulk-action, batch-action, selection-scope, copy-rec] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +## Description + +`CurrPage.SetSelectionFilter(Rec)` behaves differently depending on whether the user explicitly multi-selected rows. When no rows are marked — the cursor is simply positioned on a row — the method writes a primary key filter for that single row and leaves `MarkedOnly` as false. When the user explicitly selected multiple rows, the method marks those records and sets `MarkedOnly` to true. A batch action that calls `SetSelectionFilter` and then passes the record directly to a processing codeunit will therefore silently restrict to one row whenever the user has not made an explicit selection, which is almost never the intended behaviour for an action labelled "Verify All" or "Post All". + +The base platform avoids this ambiguity by routing batch list actions through Reports: the Report request page shows the derived filter and lets the user correct it before running. A direct codeunit call has no such safety net and must resolve the scope explicitly. + +## Best Practice + +After calling `SetSelectionFilter`, test `MarkedOnly`. When it is false — meaning the user made no explicit selection, or selected all rows with Ctrl+A — discard the single-row primary key filter by copying the page source record (`Copy(Rec)`), which carries the full page view including all active filter groups. When `MarkedOnly` is true the user made a deliberate selection and that filter should be respected as-is. Refer to `set-selection-filter-list-scope.good.al` for the pattern. + +## Anti Pattern + +Passing the result of `SetSelectionFilter` directly to a processing codeunit without checking `MarkedOnly`. When the user runs the action with the cursor on row three and no rows highlighted, the codeunit receives a filter that matches only row three. The action appears to succeed but processes a fraction of the intended scope. The defect is hard to notice because no error is raised and the single-row run completes without complaint. See `set-selection-filter-list-scope.bad.al`. + +## See also + +`Page.SetSelectionFilter` — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/page/page-setselectionfilter-method From ce60806341fc8c8d3bc89c37a9dc95bc32ba8f65 Mon Sep 17 00:00:00 2001 From: Dmitry Katson Date: Fri, 26 Jun 2026 03:58:25 -0400 Subject: [PATCH 03/86] move set-selection-filter-list-scope to microsoft layer per maintainer feedback Co-authored-by: Cursor --- .../knowledge/ui/set-selection-filter-list-scope.bad.al | 0 .../knowledge/ui/set-selection-filter-list-scope.good.al | 0 .../knowledge/ui/set-selection-filter-list-scope.md | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename {community => microsoft}/knowledge/ui/set-selection-filter-list-scope.bad.al (100%) rename {community => microsoft}/knowledge/ui/set-selection-filter-list-scope.good.al (100%) rename {community => microsoft}/knowledge/ui/set-selection-filter-list-scope.md (100%) diff --git a/community/knowledge/ui/set-selection-filter-list-scope.bad.al b/microsoft/knowledge/ui/set-selection-filter-list-scope.bad.al similarity index 100% rename from community/knowledge/ui/set-selection-filter-list-scope.bad.al rename to microsoft/knowledge/ui/set-selection-filter-list-scope.bad.al diff --git a/community/knowledge/ui/set-selection-filter-list-scope.good.al b/microsoft/knowledge/ui/set-selection-filter-list-scope.good.al similarity index 100% rename from community/knowledge/ui/set-selection-filter-list-scope.good.al rename to microsoft/knowledge/ui/set-selection-filter-list-scope.good.al diff --git a/community/knowledge/ui/set-selection-filter-list-scope.md b/microsoft/knowledge/ui/set-selection-filter-list-scope.md similarity index 100% rename from community/knowledge/ui/set-selection-filter-list-scope.md rename to microsoft/knowledge/ui/set-selection-filter-list-scope.md From d3eb7d6a9844befe1740f580e55ace7aa46d4bc4 Mon Sep 17 00:00:00 2001 From: Jeremy Vyska <35526546+JeremyVyska@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:18:15 +0200 Subject: [PATCH 04/86] Guard the custom layer and flag stray top-level entries (#58) The /custom/ layer is a template: in upstream microsoft/BCQuality it stays empty by default and is meant to be populated only inside a fork or consumer clone. PR #55 both targeted /custom/ and leaked a new top-level folder. - skills/write.md: add a fork-precondition guard so authors (human or agent) confirm they are not in microsoft/BCQuality before scaffolding /custom/ content. - Guard custom layer workflow: auto-closes upstream PRs that add/modify /custom/ content beyond the template files, with a friendly redirect-to-fork comment. - Flag new top-level entries workflow: posts an advisory (non-blocking) comment when a PR introduces an unexpected top-level folder or file for maintainer review. Both workflows run only on microsoft/BCQuality (never on forks) and read the PR file list via the API without checking out or executing PR code. Co-authored-by: Jeremy Vyska Co-authored-by: Claude Opus 4.8 (1M context) --- .github/custom-layer-autoclose.md | 31 +++++++ .github/new-top-level-flag.md | 10 +++ .github/workflows/flag-new-top-level.yml | 108 +++++++++++++++++++++++ .github/workflows/guard-custom-layer.yml | 88 ++++++++++++++++++ skills/write.md | 11 +++ 5 files changed, 248 insertions(+) create mode 100644 .github/custom-layer-autoclose.md create mode 100644 .github/new-top-level-flag.md create mode 100644 .github/workflows/flag-new-top-level.yml create mode 100644 .github/workflows/guard-custom-layer.yml diff --git a/.github/custom-layer-autoclose.md b/.github/custom-layer-autoclose.md new file mode 100644 index 0000000..f0b059e --- /dev/null +++ b/.github/custom-layer-autoclose.md @@ -0,0 +1,31 @@ +Hey @{{AUTHOR}} 👋 + +First off — thank you for jumping in and experimenting! It's awesome to see people pushing on the framework. 🎉 + +That said, let me gently redirect you, because I think there's a small but important misunderstanding about how the `custom` layer is meant to work: + +The `custom` layer in *this* repo isn't a destination for PRs — it's the designated sandbox inside **your own fork**. Think of it as the "your timeline" branch of the multiverse 🌌: this repo is canon, your fork is where you get to remix the lore without needing anyone's approval. That's the whole point of the layer existing — so you *don't* have to upstream your team-specific or experimental work. + +The intended workflow is: + +1. 🍴 **Fork** BCQuality to your own GitHub account +2. Clone *your fork* locally +3. Drop your custom agents and knowledge into the `custom` layer **there** +4. Commit and push to your fork — no PR back to upstream needed for custom stuff + +That way you get full control, your changes survive upstream updates cleanly, and you can pull in new core releases from this repo whenever you want. ✨ + +**Now — here's the fun part:** if while building out your fork you discover knowledge, patterns, or agents that you think would genuinely benefit *everyone* using BCQuality (not just your team), that's exactly what the `/community` layer is for! 🌟 PRs to `/community` here in the upstream repo are absolutely welcome and encouraged — it's how the collective hive mind 🧠 levels up. So please: tinker in your fork, and when you strike gold that's worth sharing, send it our way via `/community`. + +Going to close this PR for now (since it's targeting `custom` rather than `/community`), but please don't read it as a "no" — it's a "yes, but let's route it correctly." 🙏 Happy to help if you hit any snags spinning up your fork, and genuinely looking forward to seeing what you contribute to `/community` down the line. + +
+Files in this PR that triggered the auto-close + +{{FILES}} +
+ +May your merges be conflict-free. 🚀 + +--- +🤖 This PR was closed automatically by the `Guard custom layer` workflow because it adds or changes content under `/custom/`. If you were only updating the template (`custom/README.md` or a `.gitkeep`), a maintainer can re-open it. If you think this was closed in error, just comment here. diff --git a/.github/new-top-level-flag.md b/.github/new-top-level-flag.md new file mode 100644 index 0000000..3d297ea --- /dev/null +++ b/.github/new-top-level-flag.md @@ -0,0 +1,10 @@ + +👋 Heads up @{{AUTHOR}} — and cc maintainers — this PR introduces **new top-level entries** that aren't part of BCQuality's known repository structure: + +{{ENTRIES}} + +This isn't a block — just a flag. 🚩 New top-level folders and files are *usually* unintended (a stray export, a tool's scratch dir, or content that meant to land inside an existing layer like `/community/knowledge/`). BCQuality keeps a deliberately small root: `.github/`, `community/`, `custom/`, `microsoft/`, `skills/`, and `tools/`, plus a handful of root docs. + +**If this was intentional** and the new entry genuinely belongs at the repo root, a maintainer can review and merge as normal — no action needed beyond a quick sanity check. **If it wasn't**, please move the content into the right existing layer (or drop it) and push an update. 🙏 + +A maintainer will take a look before merging. diff --git a/.github/workflows/flag-new-top-level.yml b/.github/workflows/flag-new-top-level.yml new file mode 100644 index 0000000..4d5f1d7 --- /dev/null +++ b/.github/workflows/flag-new-top-level.yml @@ -0,0 +1,108 @@ +name: Flag new top-level entries + +# BCQuality keeps a deliberately small repository root. New top-level folders +# or files are almost always unintended — a stray export, a tool's scratch +# directory, or content that meant to land inside an existing layer (e.g. +# /community/knowledge/). PR #55 leaked exactly this kind of stray folder. +# +# Unlike the custom-layer guard, this workflow does NOT close the PR. It only +# posts a single advisory comment so a maintainer (and the author) can eyeball +# the addition. It reads the PR's file LIST via the API and never checks out or +# runs PR code. + +on: + pull_request_target: + types: [opened, reopened, synchronize] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + flag: + if: github.repository == 'microsoft/BCQuality' + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + sparse-checkout: | + .github/new-top-level-flag.md + sparse-checkout-cone-mode: false + + - name: Flag unexpected new top-level entries + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + // Known, intended repository root. Anything else added at the root + // is flagged for a human to eyeball. + const ALLOWED_DIRS = new Set([ + '.github', 'community', 'custom', 'microsoft', 'skills', 'tools', + ]); + const ALLOWED_FILES = new Set([ + '.gitignore', 'CODEOWNERS', 'LICENSE', 'README.md', + 'SECURITY.md', 'agent-consumption.md', + ]); + + const MARKER = ''; + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: prNumber, per_page: 100, + }); + + // Only consider newly-added paths — a new top-level entry can only + // appear via an added file. + const added = files + .filter((f) => f.status === 'added') + .map((f) => f.filename); + + const newDirs = new Set(); + const newFiles = new Set(); + for (const p of added) { + const slash = p.indexOf('/'); + if (slash === -1) { + // Top-level file. + if (!ALLOWED_FILES.has(p)) newFiles.add(p); + } else { + // Top-level directory. + const dir = p.slice(0, slash); + if (!ALLOWED_DIRS.has(dir)) newDirs.add(dir); + } + } + + if (newDirs.size === 0 && newFiles.size === 0) { + core.info('No unexpected new top-level entries. Nothing to flag.'); + return; + } + + // Idempotency: don't re-flag on every synchronize. + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: prNumber, per_page: 100, + }); + if (comments.some((c) => c.body && c.body.includes(MARKER))) { + core.info('Already flagged on this PR. Skipping duplicate comment.'); + return; + } + + const lines = []; + for (const d of [...newDirs].sort()) lines.push(`- 📁 \`${d}/\` (new top-level folder)`); + for (const f of [...newFiles].sort()) lines.push(`- 📄 \`${f}\` (new top-level file)`); + const entries = lines.join('\n'); + + core.warning(`Unexpected new top-level entries: ${[...newDirs, ...newFiles].join(', ')}`); + + let body = fs.readFileSync('.github/new-top-level-flag.md', 'utf8'); + body = body + .replace(/{{AUTHOR}}/g, context.payload.pull_request.user.login) + .replace(/{{ENTRIES}}/g, entries); + + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, body, + }); + + core.info(`Flagged PR #${prNumber}.`); diff --git a/.github/workflows/guard-custom-layer.yml b/.github/workflows/guard-custom-layer.yml new file mode 100644 index 0000000..c061389 --- /dev/null +++ b/.github/workflows/guard-custom-layer.yml @@ -0,0 +1,88 @@ +name: Guard custom layer + +# The /custom/ layer is a template: in upstream microsoft/BCQuality it stays +# empty by default (README.md + .gitkeep placeholders only). Custom knowledge +# and skills are partner/customer-specific and belong in a fork, never upstream. +# +# This workflow auto-closes any PR that adds or changes content under /custom/ +# (anything beyond the allowed template files). It runs only on the upstream +# repo, so forks that legitimately populate /custom/ are unaffected. +# +# pull_request_target is required so the workflow runs with a token that can +# comment on and close the PR (including PRs opened from forks). It only reads +# the PR's file LIST via the API and never checks out or executes PR code, so +# the elevated token is not exposed to untrusted content. + +on: + pull_request_target: + types: [opened, reopened, synchronize] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + guard: + # Never run on forks — a fork's /custom/ content is exactly what's supposed + # to live there. + if: github.repository == 'microsoft/BCQuality' + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + sparse-checkout: | + .github/custom-layer-autoclose.md + sparse-checkout-cone-mode: false + + - name: Close PR if it touches the custom layer + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + // Files under custom/ that ARE allowed to change (the template seed). + const ALLOWED = new Set([ + 'custom/README.md', + ]); + // Any .gitkeep under custom/ is also allowed. + const isAllowed = (p) => + ALLOWED.has(p) || /^custom\/.*\.gitkeep$/.test(p) || p === 'custom/.gitkeep'; + + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: prNumber, per_page: 100, + }); + + // Offending = added/modified/renamed/copied/changed paths under custom/ + // that are not template files. (We ignore pure deletions.) + const offending = files + .filter((f) => f.status !== 'removed') + .map((f) => f.filename) + .filter((p) => p.startsWith('custom/') && !isAllowed(p)); + + if (offending.length === 0) { + core.info('No disallowed /custom/ changes found. Nothing to do.'); + return; + } + + core.warning(`PR #${prNumber} touches the custom layer: ${offending.join(', ')}`); + + const fileList = offending.map((p) => `- \`${p}\``).join('\n'); + let body = fs.readFileSync('.github/custom-layer-autoclose.md', 'utf8'); + body = body + .replace(/{{AUTHOR}}/g, context.payload.pull_request.user.login) + .replace(/{{FILES}}/g, fileList); + + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, body, + }); + + await github.rest.pulls.update({ + owner, repo, pull_number: prNumber, state: 'closed', + }); + + core.info(`Closed PR #${prNumber}.`); diff --git a/skills/write.md b/skills/write.md index 9a3b208..754fe1e 100644 --- a/skills/write.md +++ b/skills/write.md @@ -65,6 +65,17 @@ Knowledge files do not contain code. Samples live as **sibling files** next to t - **`/community/knowledge//`** — shared community patterns. The default layer for contributions from outside the platform team. Content here can be promoted to `/microsoft/` once it proves itself. - **`/custom/knowledge//`** — partner or customer overrides. Generally does not appear in the BCQuality repository itself; `/custom/` lives in consumer repositories. +### Writing to `/custom/` — fork precondition + +The `/custom/` layer is **empty by default** in the upstream `microsoft/BCQuality` repository — it ships as a template (`README.md` plus `.gitkeep` placeholders) and is meant to be populated only inside a **fork or consumer clone** that an organization controls. Custom content is partner- or customer-specific by definition and is never accepted upstream. + +Before authoring or scaffolding any file under `/custom/knowledge/` or `/custom/skills/`, an author — human or agent — MUST confirm the working repository is **not** `microsoft/BCQuality`: + +- Check the `origin` remote: `git remote get-url origin`. If it points at `github.com/microsoft/BCQuality`, stop — you are in the upstream repo, not a fork. +- If you are in the upstream repo, do not write the file. Either fork the repository (or clone it into your organization's own repo) and add the custom content there, or — if the guidance is genuinely shareable — author it in `/community/knowledge/` instead. + +A pull request that adds `/custom/` content to `microsoft/BCQuality` will be **automatically closed** by the `Guard custom layer` workflow. Validate the fork precondition first so authoring effort is not wasted on a PR that cannot be merged. + ## Pre-PR checklist Before opening a pull request: From 292bdabe27f664b4d8e6269efa15ee762a1d5e4a Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Wed, 1 Jul 2026 10:41:01 +0200 Subject: [PATCH 05/86] Promote performance knowledge from community to Microsoft layer (#50) Pure git-mv relocation of all 7 performance articles from community/knowledge/performance/ to microsoft/knowledge/performance/. No content changes. Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../avoid-growing-globals-in-singleinstance-subscribers.bad.al | 0 .../avoid-growing-globals-in-singleinstance-subscribers.good.al | 0 .../avoid-growing-globals-in-singleinstance-subscribers.md | 0 .../choose-maintainsiftindex-by-read-write-ratio.good.al | 0 .../performance/choose-maintainsiftindex-by-read-write-ratio.md | 0 .../load-common-fields-before-branching-on-case.bad.al | 0 .../load-common-fields-before-branching-on-case.good.al | 0 .../performance/load-common-fields-before-branching-on-case.md | 0 .../load-only-primary-key-fields-for-reference-work.bad.al | 0 .../load-only-primary-key-fields-for-reference-work.good.al | 0 .../load-only-primary-key-fields-for-reference-work.md | 0 .../performance/omit-filter-only-fields-from-setloadfields.bad.al | 0 .../omit-filter-only-fields-from-setloadfields.good.al | 0 .../performance/omit-filter-only-fields-from-setloadfields.md | 0 .../knowledge/performance/order-case-branches-by-frequency.bad.al | 0 .../performance/order-case-branches-by-frequency.good.al | 0 .../knowledge/performance/order-case-branches-by-frequency.md | 0 .../performance/use-deleteall-for-filtered-bulk-deletion.bad.al | 0 .../performance/use-deleteall-for-filtered-bulk-deletion.good.al | 0 .../performance/use-deleteall-for-filtered-bulk-deletion.md | 0 20 files changed, 0 insertions(+), 0 deletions(-) rename {community => microsoft}/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.bad.al (100%) rename {community => microsoft}/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.good.al (100%) rename {community => microsoft}/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md (100%) rename {community => microsoft}/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.good.al (100%) rename {community => microsoft}/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md (100%) rename {community => microsoft}/knowledge/performance/load-common-fields-before-branching-on-case.bad.al (100%) rename {community => microsoft}/knowledge/performance/load-common-fields-before-branching-on-case.good.al (100%) rename {community => microsoft}/knowledge/performance/load-common-fields-before-branching-on-case.md (100%) rename {community => microsoft}/knowledge/performance/load-only-primary-key-fields-for-reference-work.bad.al (100%) rename {community => microsoft}/knowledge/performance/load-only-primary-key-fields-for-reference-work.good.al (100%) rename {community => microsoft}/knowledge/performance/load-only-primary-key-fields-for-reference-work.md (100%) rename {community => microsoft}/knowledge/performance/omit-filter-only-fields-from-setloadfields.bad.al (100%) rename {community => microsoft}/knowledge/performance/omit-filter-only-fields-from-setloadfields.good.al (100%) rename {community => microsoft}/knowledge/performance/omit-filter-only-fields-from-setloadfields.md (100%) rename {community => microsoft}/knowledge/performance/order-case-branches-by-frequency.bad.al (100%) rename {community => microsoft}/knowledge/performance/order-case-branches-by-frequency.good.al (100%) rename {community => microsoft}/knowledge/performance/order-case-branches-by-frequency.md (100%) rename {community => microsoft}/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al (100%) rename {community => microsoft}/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al (100%) rename {community => microsoft}/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md (100%) diff --git a/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.bad.al b/microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.bad.al similarity index 100% rename from community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.bad.al rename to microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.bad.al diff --git a/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.good.al b/microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.good.al similarity index 100% rename from community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.good.al rename to microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.good.al diff --git a/community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md b/microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md similarity index 100% rename from community/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md rename to microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md diff --git a/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.good.al b/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.good.al similarity index 100% rename from community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.good.al rename to microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.good.al diff --git a/community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md b/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md similarity index 100% rename from community/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md rename to microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md diff --git a/community/knowledge/performance/load-common-fields-before-branching-on-case.bad.al b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.bad.al similarity index 100% rename from community/knowledge/performance/load-common-fields-before-branching-on-case.bad.al rename to microsoft/knowledge/performance/load-common-fields-before-branching-on-case.bad.al diff --git a/community/knowledge/performance/load-common-fields-before-branching-on-case.good.al b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.good.al similarity index 100% rename from community/knowledge/performance/load-common-fields-before-branching-on-case.good.al rename to microsoft/knowledge/performance/load-common-fields-before-branching-on-case.good.al diff --git a/community/knowledge/performance/load-common-fields-before-branching-on-case.md b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md similarity index 100% rename from community/knowledge/performance/load-common-fields-before-branching-on-case.md rename to microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md diff --git a/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.bad.al b/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.bad.al similarity index 100% rename from community/knowledge/performance/load-only-primary-key-fields-for-reference-work.bad.al rename to microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.bad.al diff --git a/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.good.al b/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.good.al similarity index 100% rename from community/knowledge/performance/load-only-primary-key-fields-for-reference-work.good.al rename to microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.good.al diff --git a/community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md b/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.md similarity index 100% rename from community/knowledge/performance/load-only-primary-key-fields-for-reference-work.md rename to microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.md diff --git a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.bad.al b/microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.bad.al similarity index 100% rename from community/knowledge/performance/omit-filter-only-fields-from-setloadfields.bad.al rename to microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.bad.al diff --git a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.good.al b/microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.good.al similarity index 100% rename from community/knowledge/performance/omit-filter-only-fields-from-setloadfields.good.al rename to microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.good.al diff --git a/community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md b/microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.md similarity index 100% rename from community/knowledge/performance/omit-filter-only-fields-from-setloadfields.md rename to microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.md diff --git a/community/knowledge/performance/order-case-branches-by-frequency.bad.al b/microsoft/knowledge/performance/order-case-branches-by-frequency.bad.al similarity index 100% rename from community/knowledge/performance/order-case-branches-by-frequency.bad.al rename to microsoft/knowledge/performance/order-case-branches-by-frequency.bad.al diff --git a/community/knowledge/performance/order-case-branches-by-frequency.good.al b/microsoft/knowledge/performance/order-case-branches-by-frequency.good.al similarity index 100% rename from community/knowledge/performance/order-case-branches-by-frequency.good.al rename to microsoft/knowledge/performance/order-case-branches-by-frequency.good.al diff --git a/community/knowledge/performance/order-case-branches-by-frequency.md b/microsoft/knowledge/performance/order-case-branches-by-frequency.md similarity index 100% rename from community/knowledge/performance/order-case-branches-by-frequency.md rename to microsoft/knowledge/performance/order-case-branches-by-frequency.md diff --git a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al similarity index 100% rename from community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al rename to microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al diff --git a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al similarity index 100% rename from community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al rename to microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al diff --git a/community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md similarity index 100% rename from community/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md rename to microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md From 6281e7e39a0d348d006feb325ea6dd0547b19f0d Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Wed, 1 Jul 2026 10:41:24 +0200 Subject: [PATCH 06/86] Promote ui knowledge from community to Microsoft layer (#51) Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ui/default-descending-sort-on-historical-pages.bad.al | 0 .../ui/default-descending-sort-on-historical-pages.good.al | 0 .../knowledge/ui/default-descending-sort-on-historical-pages.md | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename {community => microsoft}/knowledge/ui/default-descending-sort-on-historical-pages.bad.al (100%) rename {community => microsoft}/knowledge/ui/default-descending-sort-on-historical-pages.good.al (100%) rename {community => microsoft}/knowledge/ui/default-descending-sort-on-historical-pages.md (100%) diff --git a/community/knowledge/ui/default-descending-sort-on-historical-pages.bad.al b/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.bad.al similarity index 100% rename from community/knowledge/ui/default-descending-sort-on-historical-pages.bad.al rename to microsoft/knowledge/ui/default-descending-sort-on-historical-pages.bad.al diff --git a/community/knowledge/ui/default-descending-sort-on-historical-pages.good.al b/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.good.al similarity index 100% rename from community/knowledge/ui/default-descending-sort-on-historical-pages.good.al rename to microsoft/knowledge/ui/default-descending-sort-on-historical-pages.good.al diff --git a/community/knowledge/ui/default-descending-sort-on-historical-pages.md b/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.md similarity index 100% rename from community/knowledge/ui/default-descending-sort-on-historical-pages.md rename to microsoft/knowledge/ui/default-descending-sort-on-historical-pages.md From 4119417ce4db7cd5423671fb726459c485549f6c Mon Sep 17 00:00:00 2001 From: Jeremy Vyska <35526546+JeremyVyska@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:31:50 +0200 Subject: [PATCH 07/86] Add 15 community knowledge articles from BC Code Intel ingest (#66) * Add 15 community knowledge articles from BC Code Intel ingest Ingests net-new /community knowledge from BC Code Intelligence, surviving the admission test, gray-zone salvage, and dedup against the full corpus. Domains: ui (6), error-handling (3), performance (2), upgrade (1), appsource (1), security (1), telemetry (1). The two BC24 No. Series migration drafts are merged into one article. Adds good/bad AL samples for the clean-fit articles (error-handling, performance, security, telemetry). UI and appsource remain knowledge-only. Validator and knowledge-index checks pass (207 articles). Co-Authored-By: Claude Opus 4.8 (1M context) * Correct SetLoadFields JIT-load article to match MS docs The draft claimed accessing an unlisted field "reloads the entire row" per record. Microsoft's partial-records docs say otherwise: the platform does an implicit Get that loads the missing field(s), and in a direct var loop the first JIT updates the enumerator so later iterations do not re-load. The genuine per-row penalty is the pass-by-value case, where the copy's enumerator is not updated. Rewrite the article around JIT loading and the by-value footgun, rename the slug from ...full-reload to ...jit-load, and fix the good/bad samples to demonstrate the by-value repetition accurately. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Jeremy Vyska Co-authored-by: Claude Opus 4.8 (1M context) --- ...eep-copilot-help-url-to-two-path-levels.md | 24 ++++++++++++++ .../fielderror-default-message-logic.bad.al | 23 +++++++++++++ .../fielderror-default-message-logic.good.al | 22 +++++++++++++ .../fielderror-default-message-logic.md | 20 +++++++++++ .../fielderror-vs-testfield.bad.al | 26 +++++++++++++++ .../fielderror-vs-testfield.good.al | 27 +++++++++++++++ .../error-handling/fielderror-vs-testfield.md | 20 +++++++++++ ...t-subscriber-rolls-back-whole-batch.bad.al | 13 ++++++++ ...-subscriber-rolls-back-whole-batch.good.al | 33 +++++++++++++++++++ ...event-subscriber-rolls-back-whole-batch.md | 24 ++++++++++++++ ...ll-skips-ondelete-unless-runtrigger.bad.al | 11 +++++++ ...l-skips-ondelete-unless-runtrigger.good.al | 16 +++++++++ ...eteall-skips-ondelete-unless-runtrigger.md | 24 ++++++++++++++ ...ds-unlisted-field-triggers-jit-load.bad.al | 26 +++++++++++++++ ...s-unlisted-field-triggers-jit-load.good.al | 24 ++++++++++++++ ...fields-unlisted-field-triggers-jit-load.md | 24 ++++++++++++++ .../security/secrets-isolated-storage.bad.al | 21 ++++++++++++ .../security/secrets-isolated-storage.good.al | 15 +++++++++ .../security/secrets-isolated-storage.md | 24 ++++++++++++++ ...elemetryscope-to-extensionpublisher.bad.al | 17 ++++++++++ ...lemetryscope-to-extensionpublisher.good.al | 15 +++++++++ ...lt-telemetryscope-to-extensionpublisher.md | 24 ++++++++++++++ community/knowledge/ui/factbox-design.md | 20 +++++++++++ .../knowledge/ui/fasttab-field-importance.md | 20 +++++++++++ .../knowledge/ui/page-background-tasks.md | 20 +++++++++++ ...r-actionref-syntax-for-promoted-actions.md | 20 +++++++++++ .../knowledge/ui/promoted-action-groups.md | 20 +++++++++++ .../ui/split-button-standard-groups.md | 20 +++++++++++ .../upgrade/no-series-bc24-migration.md | 20 +++++++++++ 29 files changed, 613 insertions(+) create mode 100644 community/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md create mode 100644 community/knowledge/error-handling/fielderror-default-message-logic.bad.al create mode 100644 community/knowledge/error-handling/fielderror-default-message-logic.good.al create mode 100644 community/knowledge/error-handling/fielderror-default-message-logic.md create mode 100644 community/knowledge/error-handling/fielderror-vs-testfield.bad.al create mode 100644 community/knowledge/error-handling/fielderror-vs-testfield.good.al create mode 100644 community/knowledge/error-handling/fielderror-vs-testfield.md create mode 100644 community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.bad.al create mode 100644 community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.good.al create mode 100644 community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.md create mode 100644 community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.bad.al create mode 100644 community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.good.al create mode 100644 community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.md create mode 100644 community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.bad.al create mode 100644 community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.good.al create mode 100644 community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md create mode 100644 community/knowledge/security/secrets-isolated-storage.bad.al create mode 100644 community/knowledge/security/secrets-isolated-storage.good.al create mode 100644 community/knowledge/security/secrets-isolated-storage.md create mode 100644 community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.bad.al create mode 100644 community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.good.al create mode 100644 community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.md create mode 100644 community/knowledge/ui/factbox-design.md create mode 100644 community/knowledge/ui/fasttab-field-importance.md create mode 100644 community/knowledge/ui/page-background-tasks.md create mode 100644 community/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md create mode 100644 community/knowledge/ui/promoted-action-groups.md create mode 100644 community/knowledge/ui/split-button-standard-groups.md create mode 100644 community/knowledge/upgrade/no-series-bc24-migration.md diff --git a/community/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md b/community/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md new file mode 100644 index 0000000..fa3dce5 --- /dev/null +++ b/community/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md @@ -0,0 +1,24 @@ +--- +bc-version: [24..] +domain: appsource +keywords: [app-json, help-url, copilot, grounding, documentation, url-depth, contexturl] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep the Copilot help URL to two path levels + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +The `help` URL declared in `app.json` is what Copilot uses to ground answers about your app. That URL may be at most **two path levels** deep (for example `https://contoso.com/docs/myapp`). If you point it at a deeper path (three or more segments), Copilot does not use the URL as given: it truncates to the first two levels, drops any fragments and query strings, and then grounds on **all** content beneath that two-level path. The failure is silent — there is no build error — and the practical effect is worse answers, because Copilot may ingest sibling apps' documentation that lives under the same two-level parent. + +## Best Practice + +Organize per-app documentation so the canonical help page sits no deeper than two path levels, and confirm during testing that Copilot citations resolve to your app's content rather than a broader parent. If your docs naturally nest deeper, give each app a dedicated two-level path it owns. + +## Anti Pattern + +Setting `help` to a deep, tidy-looking docs path such as `https://contoso.com/docs/products/erp/myapp/setup`. Copilot truncates it to `…/docs/products`, then grounds on everything under that node — pulling in unrelated content and degrading answer quality for your users. diff --git a/community/knowledge/error-handling/fielderror-default-message-logic.bad.al b/community/knowledge/error-handling/fielderror-default-message-logic.bad.al new file mode 100644 index 0000000..c4a57a2 --- /dev/null +++ b/community/knowledge/error-handling/fielderror-default-message-logic.bad.al @@ -0,0 +1,23 @@ +table 50120 "FieldError Default Bad" +{ + fields + { + field(1; "No."; Code[20]) { } + field(2; "Discount %"; Decimal) { } + field(3; "Currency Code"; Code[10]) { } + } + + procedure ValidateForRelease() + begin + // Re-testing a field and handing FieldError a fully-formed sentence. + // The framework already prepends the caption and appends the value, + // so this renders as "Currency Code The Currency Code field must have + // a value. in ..." — caption repeated, capital letter mid-sentence, + // stray trailing clause. + if "Currency Code" = '' then + FieldError("Currency Code", 'The Currency Code field must have a value.'); + + if "Discount %" > 100 then + FieldError("Discount %", 'The Discount % must not be greater than 100 percent.'); + end; +} diff --git a/community/knowledge/error-handling/fielderror-default-message-logic.good.al b/community/knowledge/error-handling/fielderror-default-message-logic.good.al new file mode 100644 index 0000000..0007660 --- /dev/null +++ b/community/knowledge/error-handling/fielderror-default-message-logic.good.al @@ -0,0 +1,22 @@ +table 50120 "FieldError Default Good" +{ + fields + { + field(1; "No."; Code[20]) { } + field(2; "Discount %"; Decimal) { } + field(3; "Currency Code"; Code[10]) { } + } + + procedure ValidateForRelease() + begin + // Plain required-field gate: TestField checks the condition and raises + // the error in one call, with caption and record context supplied by + // the framework. + TestField("Currency Code"); + + // Condition already evaluated: pass only a lowercase predicate so it + // reads as one sentence after the auto-inserted caption and value. + if "Discount %" > 100 then + FieldError("Discount %", 'cannot exceed 100'); + end; +} diff --git a/community/knowledge/error-handling/fielderror-default-message-logic.md b/community/knowledge/error-handling/fielderror-default-message-logic.md new file mode 100644 index 0000000..b0641dc --- /dev/null +++ b/community/knowledge/error-handling/fielderror-default-message-logic.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: error-handling +keywords: [fielderror, testfield, error-message, field-caption, lowercase-convention, record-context, validation] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Rely On FieldError's Auto-Generated Context And Pass Only A Lowercase Predicate + +> Contributions welcome — open a PR to refine or extend this article. + +## Description +`Rec.FieldError(FieldNo)` does not just print the text you give it. Business Central automatically prepends the field caption, appends the current field value (when non-blank), and suffixes the table name and primary-key values for record identification. The optional second argument is only the middle predicate of that sentence — e.g. `"must be unique"`, not a whole self-contained message. Misunderstanding this leads to messages that duplicate the caption and value or read as broken grammar, because the framework's surrounding text is built to join a lowercase fragment. + +## Best Practice +For a plain required-field check, prefer `TestField`, which tests the condition and raises the error in one call. When the condition is non-trivial and has already been evaluated, call `FieldError(FieldNo)` with no message to get the localized default (`must have a value`, `is not valid`, etc.), or pass a short lowercase predicate such as `FieldError(FieldNo, 'must be a positive number')`. Start the custom text with a lowercase letter so it reads as one sentence with the auto-inserted caption, and use a field-number reference (or the field token) rather than a hard-coded field name so captions and translations stay correct. Let the framework supply the caption, value, table, and key context for you. + +## Anti Pattern +Re-testing a condition you already evaluated, or passing a fully formed sentence like `'The Amount field must be positive.'` to `FieldError`. The result reads as `Amount The Amount field must be positive. in Gen. Journal Line ...` — capital letter mid-sentence, caption and value repeated, and a stray trailing clause. Reviewer signals: a `FieldError` argument that names the field, restates the current value, starts with a capital letter, or ends with a period. Each is a sign the author treated `FieldError` like `Error` instead of as a predicate slotted into framework-generated context. \ No newline at end of file diff --git a/community/knowledge/error-handling/fielderror-vs-testfield.bad.al b/community/knowledge/error-handling/fielderror-vs-testfield.bad.al new file mode 100644 index 0000000..17d559e --- /dev/null +++ b/community/knowledge/error-handling/fielderror-vs-testfield.bad.al @@ -0,0 +1,26 @@ +table 50122 "FieldError vs TestField Bad" +{ + fields + { + field(1; "No."; Code[20]) { } + field(2; "Posting Date"; Date) { } + field(3; "Amount"; Decimal) { } + } + + procedure PostDocument() + begin + // FieldError performs no comparison and always raises the moment it is + // reached, so this "check" terminates PostDocument every time — the + // Posting Date is never actually tested, and the amount rule below is + // dead code. + FieldError("Posting Date", 'must be filled in'); + + if IsAmountOutsideAllowedRange("Amount") then + Error('Amount is out of range.'); + end; + + local procedure IsAmountOutsideAllowedRange(Value: Decimal): Boolean + begin + exit((Value < 0) or (Value > 1000000)); + end; +} diff --git a/community/knowledge/error-handling/fielderror-vs-testfield.good.al b/community/knowledge/error-handling/fielderror-vs-testfield.good.al new file mode 100644 index 0000000..43c504e --- /dev/null +++ b/community/knowledge/error-handling/fielderror-vs-testfield.good.al @@ -0,0 +1,27 @@ +table 50122 "FieldError vs TestField Good" +{ + fields + { + field(1; "No."; Code[20]) { } + field(2; "Posting Date"; Date) { } + field(3; "Amount"; Decimal) { } + } + + procedure PostDocument() + begin + // Simple presence gate: TestField performs the check itself and raises + // only when the field is empty. Self-documenting prerequisite. + TestField("Posting Date"); + + // Business logic has already determined the value is invalid; + // FieldError raises a tailored, record-aware message with no + // condition of its own. + if IsAmountOutsideAllowedRange("Amount") then + FieldError("Amount", 'is outside the approved posting range'); + end; + + local procedure IsAmountOutsideAllowedRange(Value: Decimal): Boolean + begin + exit((Value < 0) or (Value > 1000000)); + end; +} diff --git a/community/knowledge/error-handling/fielderror-vs-testfield.md b/community/knowledge/error-handling/fielderror-vs-testfield.md new file mode 100644 index 0000000..03117da --- /dev/null +++ b/community/knowledge/error-handling/fielderror-vs-testfield.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: error-handling +keywords: [fielderror, testfield, field-validation, onvalidate, error-message, mandatory-field, record-context] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Choose `TestField` For Conditional Checks And `FieldError` For Already-Failed Validation + +> Contributions welcome — open a PR to refine or extend this article. + +## Description +`TestField` and `FieldError` look interchangeable but behave differently, and choosing the wrong one produces either dead code or a check that never fires. `TestField` performs the comparison itself and throws only when the field is empty or does not match the supplied value; `FieldError` performs no comparison and always raises an error the moment it is reached. Both attach the field caption and the record's primary-key context to the message automatically, which is why neither should be replaced by a hand-built `Error` call that interpolates the field name as a literal. + +## Best Practice +Use `TestField` when the condition is a simple presence-or-equality check on a single field — mandatory-field gates and prerequisite checks at the top of a procedure read clearly and self-document intent. Use `FieldError` inside an `OnValidate` trigger or a validation procedure where surrounding business logic has already determined the value is invalid and you want a specific, custom message. Rely on the built-in field-and-record context both methods add rather than re-stating the field name in the text. + +## Anti Pattern +Calling `FieldError` to "test" a field — placing it on a path that is reached unconditionally and expecting it to validate — terminates execution every time because `FieldError` never evaluates a condition. The inverse smell is reaching for `TestField` when the rule needs a tailored message, then bolting a vague generic string onto a check that cannot express the real business reason. A reviewer can spot the first by a `FieldError` that is not guarded by a preceding `if`, and the second by a `TestField` whose intent comment describes a condition more complex than presence or equality. diff --git a/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.bad.al b/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.bad.al new file mode 100644 index 0000000..c040ffc --- /dev/null +++ b/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.bad.al @@ -0,0 +1,13 @@ +codeunit 50124 "Sales Line Guard Bad Sample" +{ + // A throw here executes synchronously inside the transaction of the write + // that fired the event. With no per-record savepoint, it rolls back ALL + // uncommitted work since the last COMMIT — the entire batch, not just this + // line. One bad row discards every row imported before it. + [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterInsertEvent', '', false, false)] + local procedure OnAfterInsertSalesLine(var Rec: Record "Sales Line") + begin + if Rec.Quantity <= 0 then + Rec.FieldError(Quantity, 'must be greater than zero'); + end; +} diff --git a/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.good.al b/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.good.al new file mode 100644 index 0000000..6e67e53 --- /dev/null +++ b/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.good.al @@ -0,0 +1,33 @@ +codeunit 50124 "Batch Import Good Sample" +{ + procedure ImportAll(var StagingLine: Record "Sales Line") + var + FailedCount: Integer; + begin + if StagingLine.FindSet() then + repeat + // Isolate each record behind a Codeunit.Run boundary: a failure + // inside the run rolls back only that record's work, and the + // batch continues instead of discarding everything. + if not Codeunit.Run(Codeunit::"Batch Import One Line", StagingLine) then + FailedCount += 1; + until StagingLine.Next() = 0; + + if FailedCount > 0 then + Message('%1 line(s) were skipped; the rest were imported.', FailedCount); + end; +} + +codeunit 50125 "Batch Import One Line" +{ + TableNo = "Sales Line"; + + trigger OnRun() + begin + // Validation lives here. If it throws, only this line rolls back, + // because the caller wrapped the call in Codeunit.Run. + Rec.TestField("No."); + Rec.TestField(Quantity); + Rec.Insert(true); + end; +} diff --git a/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.md b/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.md new file mode 100644 index 0000000..72e7736 --- /dev/null +++ b/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: error-handling +keywords: [table-events, oninsert, onmodify, ondelete, transaction, rollback, commit, batch, subscriber] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A throw in a table-event subscriber rolls back the whole batch + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Table-trigger event subscribers (`OnAfterInsertEvent`, `OnAfterModifyEvent`, `OnAfterDeleteEvent`, and their `OnBefore` counterparts) execute synchronously inside the transaction of the write that fired them. Because AL runs on a single implicit transaction with no per-record savepoint, an error raised in such a subscriber rolls back **all work since the last `COMMIT`** — not just the record that triggered it. In a batch loop with no intermediate `COMMIT`s, a single failing record discards the entire batch. The intuition that subscriber validation fails only the current record is wrong on the BC platform. + +## Best Practice + +Decide the failure granularity deliberately. If a batch must continue past individual failures, do not throw from the table-event subscriber — collect the error (for example via `ErrorInfo`/collectible errors) and let the loop continue, or isolate each record's work behind a `Codeunit.Run` / `if Codeunit.Run() then` boundary so its failure rolls back only that record. Insert intermediate `COMMIT`s only with full awareness of the durability trade-off. + +## Anti Pattern + +Putting `Error`/`TestField`/`FieldError` validation inside a table-event subscriber and assuming it rejects just the offending record during bulk processing. The first failure unwinds every uncommitted record in the run, turning a one-row data problem into a whole-batch rollback. diff --git a/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.bad.al b/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.bad.al new file mode 100644 index 0000000..f5895cd --- /dev/null +++ b/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.bad.al @@ -0,0 +1,11 @@ +codeunit 50130 "Purge Orders Bad Sample" +{ + procedure PurgeCancelledLines(var SalesLine: Record "Sales Line") + begin + // Assumes DeleteAll fires OnDelete and cascades to reservation entries + // and item applications. It does not: parameterless DeleteAll() is + // DeleteAll(false) and skips OnDelete, so the rows vanish but their + // dependent records are orphaned. + SalesLine.DeleteAll(); + end; +} diff --git a/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.good.al b/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.good.al new file mode 100644 index 0000000..7d83c7c --- /dev/null +++ b/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.good.al @@ -0,0 +1,16 @@ +codeunit 50130 "Purge Orders Good Sample" +{ + procedure PurgeCancelledLines(var SalesLine: Record "Sales Line") + begin + // These lines have OnDelete cleanup (reservation entries, item + // application). Pass true so DeleteAll runs OnDelete per record and the + // cleanup actually happens — the row-by-row cost is accepted on purpose. + SalesLine.DeleteAll(true); + end; + + procedure PurgeStagingBuffer(var TempBuffer: Record "Name/Value Buffer" temporary) + begin + // No OnDelete logic to run: the fast, set-based form is correct here. + TempBuffer.DeleteAll(); + end; +} diff --git a/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.md b/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.md new file mode 100644 index 0000000..48d630c --- /dev/null +++ b/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [deleteall, ondelete, run-trigger, set-based-delete, bulk-delete, triggers, validation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# DeleteAll skips OnDelete unless you pass RunTrigger + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`Record.DeleteAll()` — equivalently `DeleteAll(false)` — translates to a single set-based SQL `DELETE` and **does not** run AL `OnDelete` triggers or field/table validations. Only database-level referential constraints still apply. To run `OnDelete` logic you must call `DeleteAll(true)`, which then deletes record-by-record and forfeits the set-based performance, making it equivalent to a `FindSet` loop calling `Delete(true)`. The common misconception, which training data reproduces, is that `DeleteAll` iterates and fires `OnDelete` per record; it does not. (Parameterless `Delete()` likewise defaults to `Delete(false)` and skips `OnDelete`.) + +## Best Practice + +Use `DeleteAll()` / `DeleteAll(false)` for bulk deletion only when no AL `OnDelete` cleanup is required — it is the fast, set-based form. When `OnDelete` logic must run (cascading deletes, ledger cleanup, integration events), pass `DeleteAll(true)` and accept the row-by-row cost, or refactor the cleanup to run explicitly before the bulk delete. + +## Anti Pattern + +Calling `DeleteAll()` and assuming dependent records, integration events, or validation side effects are handled by `OnDelete`. The deletion succeeds but the AL-side cleanup never runs, leaving orphaned data — and adding a manual `FindSet`/`Delete` loop "for safety" reintroduces the per-record cost the set-based form was chosen to avoid. diff --git a/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.bad.al b/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.bad.al new file mode 100644 index 0000000..a5c3036 --- /dev/null +++ b/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.bad.al @@ -0,0 +1,26 @@ +codeunit 50132 "LoadFields Bad Sample" +{ + procedure TotalReleasedAmount(): Decimal + var + SalesHeader: Record "Sales Header"; + Total: Decimal; + begin + // "Currency Code" is not listed. The helper takes SalesHeader BY VALUE, + // so the copy neither shares the load set nor updates the enumerator: + // reading the unlisted field triggers a fresh JIT load (an extra Get) + // on EVERY iteration, quietly reversing the saving. + SalesHeader.SetLoadFields("Amount Including VAT", Status); + if SalesHeader.FindSet() then + repeat + if IsLocalReleased(SalesHeader) then + Total += SalesHeader."Amount Including VAT"; + until SalesHeader.Next() = 0; + exit(Total); + end; + + local procedure IsLocalReleased(SalesHeader: Record "Sales Header"): Boolean + begin + exit((SalesHeader.Status = SalesHeader.Status::Released) and + (SalesHeader."Currency Code" = '')); + end; +} diff --git a/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.good.al b/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.good.al new file mode 100644 index 0000000..7ab9016 --- /dev/null +++ b/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.good.al @@ -0,0 +1,24 @@ +codeunit 50132 "LoadFields Good Sample" +{ + procedure TotalReleasedAmount(): Decimal + var + SalesHeader: Record "Sales Header"; + Total: Decimal; + begin + // Every field read anywhere downstream is listed — including the one + // the by-var helper reads — so no JIT load is ever triggered. + SalesHeader.SetLoadFields("Amount Including VAT", Status, "Currency Code"); + if SalesHeader.FindSet() then + repeat + if IsLocalReleased(SalesHeader) then + Total += SalesHeader."Amount Including VAT"; + until SalesHeader.Next() = 0; + exit(Total); + end; + + local procedure IsLocalReleased(var SalesHeader: Record "Sales Header"): Boolean + begin + exit((SalesHeader.Status = SalesHeader.Status::Released) and + (SalesHeader."Currency Code" = '')); + end; +} diff --git a/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md b/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md new file mode 100644 index 0000000..ddce795 --- /dev/null +++ b/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [setloadfields, partial-records, just-in-time-load, jit-load, round-trip, pass-by-value, enumerator] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Reading an unlisted field after SetLoadFields triggers a JIT load + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`SetLoadFields` loads only the named fields, but the trap is what happens when code later reads a field that was *not* listed: the platform silently issues a **just-in-time (JIT) load** — an implicit `Get` that fetches the missing field(s) in a second database round-trip. A single JIT load can erase the saving; the real danger is a JIT that repeats per record. The optimization is only a win if the listed set covers every field touched anywhere downstream, not just in the immediate code block. + +## Best Practice + +Before adding `SetLoadFields`, audit the *whole* access lifecycle of the record variable — every field read in the loop body, in called procedures, in `OnValidate`/`OnAfterGetRecord`, and in anything that receives the record — and list all of them via `SetLoadFields`/`AddLoadFields`. Be especially careful when passing a partial record **by value**: the copy does not share the load set and its enumerator is not updated, so a helper that reads an unlisted field re-triggers the JIT on *every* iteration. Pass by `var` where you can (a JIT then updates the enumerator, so later iterations don't re-load), or call `AddLoadFields` before passing by value. If you cannot enumerate the fields confidently, prefer not to call `SetLoadFields` at all. See the existing guidance on when partial records pay off (`use-setloadfields-for-partial-records`). + +## Anti Pattern + +Adding `SetLoadFields(Field1, Field2)` at the top of a loop, then reading `Field3` deeper in the body or inside a by-value helper. The code compiles and returns correct data, but pays a hidden JIT round-trip — and in the by-value case it repeats once per row, quietly reversing the gain. JIT loads also introduce `Inconsistent read` / record-modified race errors that a full non-partial load avoids. Reviewer signal: a `SetLoadFields` list that omits a field later read through that record variable, especially a record passed by value to a procedure that reads a field the caller never listed. diff --git a/community/knowledge/security/secrets-isolated-storage.bad.al b/community/knowledge/security/secrets-isolated-storage.bad.al new file mode 100644 index 0000000..7383b8a --- /dev/null +++ b/community/knowledge/security/secrets-isolated-storage.bad.al @@ -0,0 +1,21 @@ +table 50134 "Api Setup Bad Sample" +{ + fields + { + field(1; "Primary Key"; Code[10]) { } + + // A secret in an ordinary Text field is readable by anyone with table + // permission, ships in RapidStart packages and Excel exports, and + // appears in record snapshots. No DataClassification tag makes it safe; + // it belongs in IsolatedStorage instead. + field(10; "API Key"; Text[250]) + { + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "Primary Key") { Clustered = true; } + } +} diff --git a/community/knowledge/security/secrets-isolated-storage.good.al b/community/knowledge/security/secrets-isolated-storage.good.al new file mode 100644 index 0000000..eec46ec --- /dev/null +++ b/community/knowledge/security/secrets-isolated-storage.good.al @@ -0,0 +1,15 @@ +codeunit 50134 "Api Credential Good Sample" +{ + procedure StoreApiKey(ApiKey: SecretText) + begin + // Credentials live in IsolatedStorage, invisible to record reads, API + // pages, RapidStart packages, and Excel export. + IsolatedStorage.Set('ExternalApiKey', ApiKey, DataScope::Module); + end; + + procedure GetApiKey() ApiKey: SecretText + begin + if not IsolatedStorage.Get('ExternalApiKey', DataScope::Module, ApiKey) then + Error('The external API key has not been configured.'); + end; +} diff --git a/community/knowledge/security/secrets-isolated-storage.md b/community/knowledge/security/secrets-isolated-storage.md new file mode 100644 index 0000000..c87753d --- /dev/null +++ b/community/knowledge/security/secrets-isolated-storage.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: security +keywords: [isolatedstorage, secrets, api-key, oauth-token, connection-string, table-field, credentials] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A secret belongs in IsolatedStorage, never in a table field + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +API keys, OAuth tokens, client secrets, and connection strings must not be stored in an ordinary table `Text` field — not even on a hidden setup table. A regular field is exposed through record reads, page display, RapidStart and Excel export, report datasets, and surfaces in `DataClassification` review; anyone with table permission can read it. The correct home is `IsolatedStorage`, which is invisible to database queries, API pages, and configuration packages. The storage-*location* decision is the rule here; how to scope and encrypt the value once it is in IsolatedStorage is covered separately. + +## Best Practice + +Persist every credential with `IsolatedStorage`, write it at the point of capture, and read it only when needed. For the per-secret details — choosing the right `DataScope`, encrypting at rest, and typing the value as `SecretText` so it cannot leak into logs — follow `isolatedstorage-datascope-module-vs-company`, `isolatedstorage-setencrypted-for-sensitive-values`, and `secrettext-for-credentials`. + +## Anti Pattern + +A "Setup" or "Connection" table carrying a `Text` field named `API Key`, `Password`, or `Client Secret`. The value is now readable by any object with table permission, ships in RapidStart packages and Excel exports, and appears in record snapshots — a credential disclosure that no amount of encryption-in-transit elsewhere makes up for. Reviewer signal: a secret-shaped field declared on a table instead of an `IsolatedStorage` call. diff --git a/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.bad.al b/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.bad.al new file mode 100644 index 0000000..75856a6 --- /dev/null +++ b/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.bad.al @@ -0,0 +1,17 @@ +codeunit 50136 "Telemetry Bad Sample" +{ + procedure LogSyncDiagnostic(RecordsProcessed: Integer) + var + Dimensions: Dictionary of [Text, Text]; + begin + Dimensions.Add('recordsProcessed', Format(RecordsProcessed)); + + // TelemetryScope::All pushes this internal diagnostic into every + // customer's Application Insights too, inflating their ingestion cost + // and burying their own signals in noise. ExtensionPublisher is the + // correct scope for publisher-only diagnostics. + Session.LogMessage( + 'SYNC001', 'Nightly sync completed.', Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::All, Dimensions); + end; +} diff --git a/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.good.al b/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.good.al new file mode 100644 index 0000000..c0e9e17 --- /dev/null +++ b/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.good.al @@ -0,0 +1,15 @@ +codeunit 50136 "Telemetry Good Sample" +{ + procedure LogSyncDiagnostic(RecordsProcessed: Integer) + var + Dimensions: Dictionary of [Text, Text]; + begin + Dimensions.Add('recordsProcessed', Format(RecordsProcessed)); + + // A diagnostic only the publisher acts on: route it to the publisher's + // own Application Insights, not the customer's environment resource. + Session.LogMessage( + 'SYNC001', 'Nightly sync completed.', Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, Dimensions); + end; +} diff --git a/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.md b/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.md new file mode 100644 index 0000000..8b41662 --- /dev/null +++ b/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: telemetry +keywords: [telemetry, session-logmessage, telemetryscope, application-insights, extensionpublisher, ingestion-cost] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Default TelemetryScope to ExtensionPublisher, not All + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +The `TelemetryScope` parameter of `Session.LogMessage` (and `LogError`) controls *where* a custom telemetry signal is routed, not just whether it is emitted. `TelemetryScope::ExtensionPublisher` sends the signal only to the extension publisher's own Application Insights resource. `TelemetryScope::All` sends it to **both** the publisher's resource **and** the customer's environment-level Application Insights resource. The distinction is easy to get wrong because both values compile and both "emit telemetry" — but `All` silently adds to the customer's ingestion volume and cost. + +## Best Practice + +Default to `TelemetryScope::ExtensionPublisher` for diagnostic telemetry that only the publisher acts on. Reserve `TelemetryScope::All` for signals the customer's own administrators are expected to monitor and act on (for example, a business event surfaced to their environment telemetry). Treat the choice as a deliberate routing decision per signal, not a copy-paste default. + +## Anti Pattern + +Emitting all custom telemetry with `TelemetryScope::All` "to be safe." This pushes the publisher's internal diagnostics into every customer's Application Insights, inflating their ingestion cost and burying their own signals in noise — a footgun a code reviewer can catch by flagging `All` on any signal the customer would not act on. diff --git a/community/knowledge/ui/factbox-design.md b/community/knowledge/ui/factbox-design.md new file mode 100644 index 0000000..b4f0544 --- /dev/null +++ b/community/knowledge/ui/factbox-design.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [factbox, subpagelink, listpart, cardpart, page-part, related-information, flowfield-sift] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Filter ListPart FactBoxes With SubPageLink To The Parent Record + +> Contributions welcome — open a PR to refine or extend this article. + +## Description +A FactBox is a page `part` that surfaces related data beside the main record so users avoid navigating away. Every FactBox runs a database query as its host page loads, so an unfiltered one is a hidden performance tax paid on every page open. The remedial trap: a `ListPart` FactBox with no `SubPageLink` does not show "the related rows" — it loads and pages through the entire source table, because nothing ties it to the host record. This makes correct `SubPageLink` linkage, not visual layout, the load-bearing design decision. + +## Best Practice +Give every `ListPart` FactBox a `SubPageLink` that maps a field on the part's source table to a `field()` of the host record (for example `SubPageLink = "Document No." = field("No.")`), so it returns only rows belonging to the current record. Prefer a `CardPart` when you only need summary figures (balance, availability, status) — it reads a single record and avoids list overhead entirely. When a FactBox shows FlowFields, ensure the calculated total is backed by a SIFT key (`MaintainSIFTIndex`) so the sum is read from the index rather than aggregated row-by-row on each load. Keep FactBox count modest and avoid heavy `OnAfterGetRecord` logic in the part. + +## Anti Pattern +Adding a `ListPart` FactBox without a `SubPageLink`, expecting it to "just show related lines." The consequence is a full-table scan on every page load that grows with the dataset and is felt worst on list pages, where the FactBox re-queries on each row selection. Reviewer signal: any `part(...)` referencing a list-type page part where the `SubPageLink` property is absent, or a FactBox FlowField filtered on non-indexed fields. A second smell is duplicating data already on the page or stacking many FactBoxes, which multiplies queries for little context gain. diff --git a/community/knowledge/ui/fasttab-field-importance.md b/community/knowledge/ui/fasttab-field-importance.md new file mode 100644 index 0000000..f0ef844 --- /dev/null +++ b/community/knowledge/ui/fasttab-field-importance.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [importance, promoted, additional, fasttab, show-more, summary-line, progressive-disclosure, field-visibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Set Field Importance To Drive FastTab Progressive Disclosure + +> Contributions welcome — open a PR to refine or extend this article. + +## Description +A FastTab field's `Importance` property controls whether the field is visible immediately, hidden behind "Show more", or surfaced on the collapsed FastTab header summary line. The three values are `Standard` (the default, shown in the expanded FastTab), `Promoted` (also rendered on the FastTab header when the tab is collapsed), and `Additional` (hidden until the user clicks "Show more"). Misusing these values either clutters the summary line or buries fields users need on every transaction, so reviewers should treat `Importance` as a deliberate layout decision rather than an afterthought. + +## Best Practice +Promote only the two to four identifying fields per FastTab that users must read at a glance without expanding — name, status, key amount — so the collapsed header summary line stays scannable. Leave the everyday working fields at `Standard`, and push rarely-touched fields (legacy compatibility fields, system timestamps, seldom-changed configuration) to `Additional`. Note that field-level `Importance = Promoted` is unrelated to action promotion on the page action bar; it governs FastTab field visibility only. Do not rely on initial expand or collapse state, which you cannot set programmatically and which the platform may personalize per user — design assuming any FastTab may be collapsed. + +## Anti Pattern +Setting `Importance = Promoted` on most fields of a FastTab so "everything is important" defeats progressive disclosure: the collapsed summary line overflows and conveys nothing at a glance. The opposite failure is marking frequently edited fields `Additional`, forcing users to click "Show more" on every record. A detectable signal is a FastTab whose fields are nearly all `Promoted`, or a FastTab containing only `Additional` fields, which renders as an empty tab until expanded. diff --git a/community/knowledge/ui/page-background-tasks.md b/community/knowledge/ui/page-background-tasks.md new file mode 100644 index 0000000..83fd87c --- /dev/null +++ b/community/knowledge/ui/page-background-tasks.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [enqueuebackgroundtask, async-calculation, child-session, factbox, cue-tile, onaftergetcurrrecord, responsive-page, read-only] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Offload Slow Read-Only Page Calculations To Background Tasks + +> Contributions welcome — open a PR to refine or extend this article. + +## Description +Pages that compute statistics, aggregates, or external lookups inline block the page from rendering until the calculation finishes, producing a visible freeze on FactBoxes, cue tiles, and calculated fields. Business Central provides page background tasks: `CurrPage.EnqueueBackgroundTask` runs a dedicated codeunit in a read-only child session and returns values via `OnPageBackgroundTaskCompleted`, so the page opens immediately and fills in computed values as they arrive. This matters because users should never wait on a calculation they may not need. The mechanism has specific rules that are easy to get wrong, which is why it warrants an explicit pattern. + +## Best Practice +Move any noticeable read-only computation off the synchronous render path into a background task. Enqueue from `OnAfterGetCurrRecord` so the task is tied to the currently focused record, and pass small payloads through the `Dictionary of [Text, Text]` input/output, converting types with `Format` and `Evaluate`. Keep each task focused on one value or a small related set rather than one large task, and show a placeholder until results land. Because tasks auto-cancel when the page closes, the record changes, or a same-ID task is re-enqueued, always supply sensible defaults and handle the timeout path in `OnPageBackgroundTaskError` — never let critical functionality depend on completion. For tests, drive the task synchronously with `RunPageBackgroundTask`. + +## Anti Pattern +Enqueuing from `OnAfterGetRecord` on a list page fires the task for every row, and each cancels the instant the selection moves to the next row — pure wasted child-session churn; a reviewer spots `EnqueueBackgroundTask` called from `OnAfterGetRecord` (or from `OnOpenPage`, where the record context is not yet stable). The other tell is a task codeunit attempting a database write or `Modify`: background tasks run read-only and the write fails at runtime. Inline heavy calculation directly in `OnAfterGetCurrRecord` with no task at all is the baseline smell — it reintroduces the page freeze the feature exists to remove. diff --git a/community/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md b/community/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md new file mode 100644 index 0000000..a3fc9dd --- /dev/null +++ b/community/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md @@ -0,0 +1,20 @@ +--- +bc-version: [21..] +domain: ui +keywords: [actionref, promoted-actions, area-promoted, promotedcategory, promotedonly, action-bar, legacy-syntax] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Promote Actions With The Modern `actionref` Syntax, Never The Legacy `Promoted` Properties + +> Contributions welcome — open a PR to refine or extend this article. + +## Description +Business Central 2022 release wave 2 (v21) introduced the `area(Promoted)` block with `actionref` as the way to promote page actions, separating an action's definition from its promotion. The older approach set `Promoted`, `PromotedCategory`, `PromotedOnly`, and `PromotedIsBig` directly on each action. The two syntaxes cannot be mixed within a single page or page extension, and choosing the legacy one entangles definition with presentation, making the action bar harder to maintain and to extend. + +## Best Practice +For new pages and page extensions, define actions in their normal `area`, then promote selected ones with `actionref` inside `area(Promoted)`, grouping them under explicit categories such as `Category_Process` and entity-named groups. This keeps each action defined once and referenced where it should appear, supports split buttons via `ShowAs`, and lets an extension promote a base action without redefining it. When extending a page, you may use modern syntax even if the base page used legacy properties (and vice versa) — the no-mixing rule is per-object, not per-dependency-tree. + +## Anti Pattern +Setting `Promoted = true` (with `PromotedCategory`, `PromotedOnly`, or `PromotedIsBig`) on actions in new code, or attempting to combine those properties with an `area(Promoted)` block in the same object — the latter fails to compile. The reviewer signal is any `Promoted`-prefixed property on an action in a newly authored page or page extension; flag it and convert to `actionref` (VS Code offers an automated conversion). Note separately that once an action is promoted in a published app, removing the promotion is a breaking change (AS0031/AW0013), so promote conservatively rather than walking it back later. diff --git a/community/knowledge/ui/promoted-action-groups.md b/community/knowledge/ui/promoted-action-groups.md new file mode 100644 index 0000000..4e009ad --- /dev/null +++ b/community/knowledge/ui/promoted-action-groups.md @@ -0,0 +1,20 @@ +--- +bc-version: [21..] +domain: ui +keywords: [action-groups, area-promoted, actionref, showas, split-button, group-caption, navigate-group, entity-group] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Use Standard Promoted Action Group Names And Placements + +> Contributions welcome — open a PR to refine or extend this article. + +## Description +Business Central ships a fixed vocabulary of promoted action groups, and users build muscle memory around where each kind of action lives. When you define `area(Promoted)` groups, reusing the standard caption and placement for a given action class makes the page feel native; inventing your own caption or putting an action in the wrong group forces every user to relearn your page. Frontier models tend to emit plausible-but-nonstandard captions (`Go To`, `Vendor Actions`, `Related`) instead of the established BC names, which is exactly what breaks cross-page consistency. + +## Best Practice +Map each action to its conventional group and use the exact standard caption: `Home`/`Process` for data-modifying and workflow actions (entity/card/document pages use `Home`, lists and worksheets use `Process`); an entity-named group (`Customer`, `Item`, `Order`) for navigation tied to the current record (statistics, ledger entries, dimensions); `Navigate` for related pages that are useful regardless of the selected record; `Report` for printing and analysis; and the workflow groups `Posting`, `Release`, `Approve`, `Request Approval`, and `Prepare` for their respective document lifecycle actions. Only `Posting` (Post / Post and Print / Preview) and `Release` (Release / Reopen) should render as split buttons via `ShowAs = SplitButton`; everything else is a normal dropdown. Within a common group keep the same action sequence you see on the matching base-app page (e.g. mirror Sales Order for a sales document) so order stays predictable. + +## Anti Pattern +Custom captions for what is really a standard group (`Vendor Actions` instead of the `Vendor` entity group, `Go To` instead of `Navigate`), posting or statistics actions dropped into the wrong group, or many tiny one-action groups that fragment the ribbon. The reviewer signal is an `area(Promoted)` block whose `group` captions do not match the base-application names for the same page type, or a `ShowAs = SplitButton` on anything other than `Posting`/`Release`. diff --git a/community/knowledge/ui/split-button-standard-groups.md b/community/knowledge/ui/split-button-standard-groups.md new file mode 100644 index 0000000..eeac8f2 --- /dev/null +++ b/community/knowledge/ui/split-button-standard-groups.md @@ -0,0 +1,20 @@ +--- +bc-version: [21..] +domain: ui +keywords: [showas, splitbutton, promoted-actions, actionref, posting-actions, release-action, action-bar] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Reserve `ShowAs = SplitButton` For Standard Posting And Release Groups + +> Contributions welcome — open a PR to refine or extend this article. + +## Description +Setting `ShowAs = SplitButton` on a `group` inside `area(Promoted)` renders a primary one-click button with a dropdown of related alternatives, where the FIRST `actionref` in the group becomes the primary (left) button. Business Central users have learned this pattern from the two standard groups it ships with — Posting (`Post`, `Post and Print`, `Post and Send`, `Preview Posting`) and Release (`Release`, `Reopen`). Inventing new split-button groups for unrelated actions, or ordering the dropdown so the most common action is not first, breaks that learned muscle memory and makes users guess what the left button will do. + +## Best Practice +Use `ShowAs = SplitButton` only when all hold: the actions are genuinely variations of one operation, there is an obvious most-frequent primary, and the dropdown stays at roughly two to four items. Place that primary action as the first `actionref` so it occupies the left button; order the remaining refs by descending frequency. Outside the Posting and Release conventions, treat a new split-button group as something to justify, not a default — a plain promoted group or category is usually the safer choice and keeps the action bar predictable. + +## Anti Pattern +Grouping unrelated actions under one split button to save toolbar space — for example pairing `Post` with `Delete`, or `Release` with `Print` — so the left button performs whatever happens to be listed first. The reviewer signal is a group with `ShowAs = SplitButton` whose member `actionref`s do not share a verb or workflow, a primary that is not the most common action, or a dropdown padded well beyond four items. Each makes the immediate left-click unpredictable and costs the user the very click the split button was meant to save. diff --git a/community/knowledge/upgrade/no-series-bc24-migration.md b/community/knowledge/upgrade/no-series-bc24-migration.md new file mode 100644 index 0000000..d0500c9 --- /dev/null +++ b/community/knowledge/upgrade/no-series-bc24-migration.md @@ -0,0 +1,20 @@ +--- +bc-version: [24..] +domain: upgrade +keywords: [no-series, noseriesmanagement, codeunit-310, getnextno, peeknextno, testmanual, arerelated, no-series-batch, business-foundation, obsolete-codeunit] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Migrate No. Series Calls From NoSeriesManagement To The BC24 No. Series Module + +> Contributions welcome — open a PR to refine or extend this article. + +## Description +In BC24 (2024 Wave 1) Microsoft moved number generation into the Business Foundation `No. Series` codeunit (310) and obsoleted the legacy `NoSeriesManagement` codeunit (396). Code that still declares `Codeunit NoSeriesManagement` or calls its methods compiles only against the temporary obsolete shim and will break once Microsoft removes it. The new API is not a drop-in rename: the facade exposes a small, specific set of real methods, parameter shapes changed, and the old single method that both previewed and consumed a number was split into two. Getting the mapping wrong silently consumes numbers when you only meant to preview, leaving gaps in the sequence. + +## Best Practice +Replace the `NoSeriesManagement` variable with `Codeunit "No. Series"` and map each call deliberately using the facade's actual methods — `GetNextNo`, `PeekNextNo`, `GetLastNoUsed`, `TestManual`, `IsManual`, and `AreRelated`. Use `GetNextNo(SeriesCode, RefDate)` only when you intend to consume and advance the series for a committed document, and `PeekNextNo(SeriesCode, RefDate)` for any display, validation, or preview-posting path where you must not consume. Replace `InitSeries` with a guarded `if "No." = '' then "No." := NoSeries.GetNextNo(...)`. Map `SelectSeries` to `LookupRelatedNoSeries`, relationship checks the old code did by hand to `AreRelated`, and both `TestManual` and `ManualNoAllowed` to `TestManual` (which now raises its own error). For multi-document allocation use `Codeunit "No. Series - Batch"` and persist its state once with `SaveState` instead of committing per iteration. Treat the migration as an opportunity to add preview-posting support, since `PeekNextNo` now makes that trivial. + +## Anti Pattern +Mechanically swapping the codeunit reference while keeping the old boolean call shape. The legacy `GetNextNo(Series, Date, false)` meant "peek" and `GetNextNo(Series, Date, true)` meant "consume"; the new `GetNextNo` always consumes and takes no boolean. Equally common is inventing validation helpers such as `IsValidNo`, `VerifySeriesExists`, `IsValidForDate`, or `TryGetNextNo` — these names are not on the `No. Series` or `No. Series - Batch` codeunits and will not compile, a frequent LLM hallucination for this migration. A reviewer can detect the defect by the residual third boolean argument, by any lingering `NoSeriesMgt`/`NoSeriesManagement` identifier, by a fabricated method name, or by an `OnBeforeGetNextNo`/`OnAfterGetNextNo` subscriber — those events were removed without replacement, so that logic must be rewritten as inline pre/post procedures, not re-subscribed. A subtler signal is `GetNextNo` used merely to display a preview, which silently advances the series and creates number gaps; that should be `PeekNextNo`. From 47babc5ef0bb54ab2ee9b3289b906bc1cde8b829 Mon Sep 17 00:00:00 2001 From: Wenjie Fan <31087545+gggdttt@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:41:41 +0200 Subject: [PATCH 08/86] Package BCQuality as an installable plugin (experiment) (#84) * Package BCQuality as an installable plugin (experiment) Adds a marketplace + plugin manifest and a bridge review skill that drives the existing Entry protocol from a plugin host. No changes to knowledge or routing logic. * Bridge skill: make enabled-layers configurable; note layer-pruning limit and manifest choice Addresses PR #84 review (JesperSchulz): #2 layer policy has no hook in the plugin path -> expose BCQUALITY_ENABLED_LAYERS and document that it narrows discovery only, not a hard deny; #3 document the .claude-plugin manifest choice (verified on Copilot CLI). --------- Co-authored-by: wenjiefan --- .claude-plugin/marketplace.json | 15 +++++ .claude-plugin/plugin.json | 9 +++ skills/bcquality-al-review/SKILL.md | 91 +++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 skills/bcquality-al-review/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..6aa3f8a --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "bcquality", + "owner": { + "name": "microsoft/BCQuality", + "url": "https://github.com/microsoft/BCQuality" + }, + "plugins": [ + { + "name": "bcquality", + "source": "./", + "description": "Business Central AL quality knowledge base and review skills, packaged as an installable plugin. Ships the entire BCQuality tree (skills, knowledge, tools) so the Entry routing protocol runs against the installed clone.", + "version": "0.1.0" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..bacea2a --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "bcquality", + "description": "Quality skills and knowledge for Business Central development. Exposes a review bridge skill that drives the BCQuality Entry protocol over the installed knowledge base.", + "version": "0.1.0", + "author": { + "name": "microsoft/BCQuality", + "url": "https://github.com/microsoft/BCQuality" + } +} diff --git a/skills/bcquality-al-review/SKILL.md b/skills/bcquality-al-review/SKILL.md new file mode 100644 index 0000000..937e06a --- /dev/null +++ b/skills/bcquality-al-review/SKILL.md @@ -0,0 +1,91 @@ +--- +name: bcquality-al-review +description: Review Business Central AL code changes using the BCQuality knowledge base. Use when reviewing an AL pull request, a working-tree diff, or a single AL file, and you want findings backed by BCQuality's curated, BC-specific quality rules. +--- + +# BCQuality AL review + +This skill drives the BCQuality **Entry protocol** over the knowledge base that ships +inside this plugin. It is the plugin entry point for consumers (orchestrators, CLIs) +that do not already know BCQuality's internal conventions — the only convention they +need is "invoke this skill for an AL review." + +BCQuality itself is orchestrator-agnostic content: knowledge files plus routing and +action skills. This bridge is the thin consumer glue that lets a plugin host run that +content without hardcoding BCQuality's layout. + +## When to use + +- Reviewing an AL pull request or an uncommitted working-tree diff. +- Reviewing a single AL file. +- Any task whose goal is "review Business Central / AL code for quality issues." + +Do **not** use this skill to *generate* AL code — it only reviews. + +## Plugin root + +Resolve `PLUGIN_ROOT` to the directory that contains this plugin's +`.claude-plugin/plugin.json`. This skill lives at +`PLUGIN_ROOT/skills/bcquality-al-review/SKILL.md`, so `PLUGIN_ROOT` is two levels up +from this file. All paths below are relative to `PLUGIN_ROOT`. If the host exposes a +plugin-root environment variable, prefer it. + +## Steps + +1. **Refresh the knowledge index (best effort).** If `pwsh` is available, run + `pwsh PLUGIN_ROOT/tools/Build-KnowledgeIndex.ps1` from `PLUGIN_ROOT` to (re)generate + `PLUGIN_ROOT/knowledge-index.json` over the installed tree. This is a discovery + accelerator only — if `pwsh` is missing or the build fails, continue; the review + skills fall back to path-based discovery. + +2. **Run Entry.** Read `PLUGIN_ROOT/skills/entry.md` and execute it against a + task context describing the review: + + ```yaml + task-context: + goal: "Review the AL changes for quality issues" + inputs-available: [pr-diff] # or [file-path] for single-file review + technologies: [al] + enabled-layers: [microsoft, community, custom] # see "Layer selection" below + ``` + + **Layer selection.** `enabled-layers` defaults to all three layers. A host can + narrow it by setting the `BCQUALITY_ENABLED_LAYERS` environment variable to a + comma-separated subset (e.g. `microsoft` or `microsoft,community`); when set, pass + exactly those layers instead of the default. This is the plugin path's only knob + for layer policy — see the limitation in Notes. + + Fill `bc-version`, `countries`, and `application-area` only when the caller + supplies them; omit them otherwise (an omitted dimension is unconstrained). + +3. **Follow the dispatch record.** Entry returns a dispatch record naming the action + skill(s) to invoke — for a PR review this is normally + `microsoft/skills/review/al-code-review.md`. For each dispatched skill, read the + file and execute its Source → Relevance → Worklist → Action steps, reading + `PLUGIN_ROOT/skills/read.md` and `PLUGIN_ROOT/skills/do.md` on demand. + +4. **Emit findings.** Produce the rolled-up findings report in the DO output contract + (`outcome`, `findings`, `references`, `confidence`, `suppressed`). Do not invent a + different shape; downstream consumers parse the DO contract without skill-specific + logic. + +If Entry returns `no-match` or `failed`, return the dispatch record unchanged so the +caller can log the reason. + +## Notes + +- This skill adds nothing to BCQuality's knowledge or routing logic; it only bootstraps + the existing Entry protocol from a plugin host. Knowledge and skill changes belong in + the layers under `PLUGIN_ROOT/microsoft/`, `PLUGIN_ROOT/community/`, and + `PLUGIN_ROOT/custom/`, not here. +- **Layer pruning is coarser than the URL/clone model.** In the clone model a consumer + prunes its checkout to policy *before* the agent runs, and the knowledge index is + rebuilt over the pruned tree, so a denied layer can never leak into discovery. A + plugin install ships the whole tree, so this bridge can only *narrow discovery* via + `enabled-layers` (`BCQUALITY_ENABLED_LAYERS`) — the denied layers' files still exist on + disk. Treat `enabled-layers` as a selection filter, not a hard security boundary. A + future revision could add a genuine deny mechanism (e.g. pruning the installed tree). +- **Manifest location.** This plugin uses `.claude-plugin/plugin.json`, which both + Claude Code and Copilot CLI accept (verified with Copilot CLI: `plugin install` + reports the bridge skill loaded). Copilot CLI also accepts a root `plugin.json`; if a + future host only reads the root form, dual-home the manifest. From 3aa3581f9563b64e6fa370cc722dbca23775f225 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 9 Jul 2026 10:24:51 +0200 Subject: [PATCH 09/86] Add testing knowledge: UI handlers, table relations, asserterror, fixtures (P1+P2) (#62) * Add testing knowledge: UI handlers, table relations, asserterror, fixtures (P1+P2) Six BC-specific testing-domain knowledge articles in community/knowledge/testing/, each with .good.al/.bad.al samples: - ui-calls-require-test-handlers - tablerelation-requires-prerequisite-records - handlers-enqueue-never-assert - handlerfunctions-attribute-must-match-ui-path - asserterror-needs-expectederror-and-code - use-library-codeunits-for-test-fixtures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move testing knowledge from community to microsoft layer Relocates the six P1+P2 testing-domain articles (18 files: .md + .good.al + .bad.al each) from community/knowledge/testing/ to microsoft/knowledge/testing/ per maintainer request. Pure git-mv rename; no content or frontmatter changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: merge handler articles, adopt enqueue-driven pattern Respond to @nikolakukrika's review on #62: - Merge ui-calls-require-test-handlers, handlerfunctions-attribute-must-match-ui-path and handlers-enqueue-never-assert into a single ui-handlers-in-tests article. - Adopt the enqueue-from-test / dequeue-and-assert-in-handler pattern using Assert.ExpectedConfirm/ExpectedMessage (substring match), with Initialize() clearing LibraryVariableStorage and AssertEmpty() proving exact call counts. - asserterror sample now uses Assert.ExpectedTestFieldError + FieldCaption instead of hardcoded message/code; article text points to the library helpers. - Drop the tablerelation article and fold its test-relevant ordering point (relations checked on Validate/Insert(true); build parents first) into use-library-codeunits-for-test-fixtures. Article count 198 -> 195. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...terror-needs-expectederror-and-code.bad.al | 18 ++++++ ...error-needs-expectederror-and-code.good.al | 26 +++++++++ ...sserterror-needs-expectederror-and-code.md | 26 +++++++++ .../testing/ui-handlers-in-tests.bad.al | 43 ++++++++++++++ .../testing/ui-handlers-in-tests.good.al | 57 +++++++++++++++++++ .../knowledge/testing/ui-handlers-in-tests.md | 28 +++++++++ ...library-codeunits-for-test-fixtures.bad.al | 25 ++++++++ ...ibrary-codeunits-for-test-fixtures.good.al | 28 +++++++++ ...use-library-codeunits-for-test-fixtures.md | 26 +++++++++ 9 files changed, 277 insertions(+) create mode 100644 microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.bad.al create mode 100644 microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.good.al create mode 100644 microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md create mode 100644 microsoft/knowledge/testing/ui-handlers-in-tests.bad.al create mode 100644 microsoft/knowledge/testing/ui-handlers-in-tests.good.al create mode 100644 microsoft/knowledge/testing/ui-handlers-in-tests.md create mode 100644 microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.bad.al create mode 100644 microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.good.al create mode 100644 microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md diff --git a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.bad.al b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.bad.al new file mode 100644 index 0000000..26b0ee4 --- /dev/null +++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.bad.al @@ -0,0 +1,18 @@ +codeunit 50409 "Test AssertError Bad" +{ + Subtype = Test; + + [Test] + procedure BlankNameIsRejectedWithSpecificError() + var + Customer: Record Customer; + begin + Customer.Init(); + Customer.Name := ''; + + // Bare asserterror: passes if ANY error is raised. A relation error, + // a permission error, or a typo elsewhere would all satisfy it — so + // this never proves the blank-name guard is the thing that fired. + asserterror Customer.TestField(Name); + end; +} diff --git a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.good.al b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.good.al new file mode 100644 index 0000000..fcbcfe6 --- /dev/null +++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.good.al @@ -0,0 +1,26 @@ +codeunit 50408 "Test AssertError Good" +{ + Subtype = Test; + + [Test] + procedure BlankNameIsRejectedWithSpecificError() + var + Customer: Record Customer; + begin + Customer.Init(); + Customer.Name := ''; + + // [WHEN] a mandatory field is blank + asserterror Customer.TestField(Name); + + // [THEN] verify the SPECIFIC failure through a reusable Library helper + // instead of hardcoding the localized message and the 'TestField' code. + // ExpectedTestFieldError centralizes that knowledge, so the test keeps + // working when the caption or code changes; FieldCaption avoids pinning + // the field name as a literal. + Assert.ExpectedTestFieldError(Customer.FieldCaption(Name), ''); + end; + + var + Assert: Codeunit "Library Assert"; +} diff --git a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md new file mode 100644 index 0000000..0dee645 --- /dev/null +++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: testing +keywords: [asserterror, expectederror, expectederrorcode, negative-test, error-code] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pin asserterror to a specific error with ExpectedError and ExpectedErrorCode + +## Description + +`asserterror` passes when the guarded statement raises any error at all. That is too permissive for a negative test: a typo, a missing setup record, or a permission failure all raise errors, so a bare `asserterror` can go green while never exercising the rule it claims to verify — false confidence that the validation works. Constrain it. `Assert.ExpectedError(text)` checks the message of the error that was actually raised, and `Assert.ExpectedErrorCode(code)` checks its error code. Together they assert that the specific failure occurred, turning "something went wrong" into "the right thing went wrong for the right reason". + +## Best Practice + +Follow every `asserterror` with a verification of the error it expects, and prefer the reusable `Library Assert` helpers over hardcoded literals. For a mandatory-field check, `Assert.ExpectedTestFieldError(FieldCaption, ExpectedValue)` encapsulates both the message and the `TestField` code, so the test survives caption or code changes and does not repeat that knowledge in every method. For other errors, pair `Assert.ExpectedError` with a stable substring — ideally a shared `Label`, not an inline sentence — and, where known, `Assert.ExpectedErrorCode`. When a needed check is missing from the shared library, extend `Library Assert` (or your own assert library) with a helper rather than hardcoding message text and codes across tests; matching on a code or an invariant fragment keeps the test from going blind to the wrong error when a caption is localized. + +See sample: `asserterror-needs-expectederror-and-code.good.al`. + +## Anti Pattern + +`asserterror DoInvalid();` with nothing after it. The test asserts only that the call failed somehow; swap the validation for a different bug and the test still passes, certifying a guard that may no longer fire. A negative test that cannot tell one error from another verifies almost nothing. + +See sample: `asserterror-needs-expectederror-and-code.bad.al`. diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al b/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al new file mode 100644 index 0000000..1ecf47d --- /dev/null +++ b/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al @@ -0,0 +1,43 @@ +codeunit 50401 "Test UI Handlers Bad" +{ + Subtype = Test; + + // Several wiring mistakes, each of which fails at runtime rather than as a + // clean assertion the reviewer can read: + // * A UI call with no listed handler -> "unhandled UI" abort (the Message + // below has no handler). + // * The mirror mistake, listing a handler the path never hits, instead + // fails with "handler function was not executed". + // * A handler that hardcodes its answer and asserts inline, with no + // enqueue/dequeue -> nothing proves the RIGHT dialog fired the RIGHT + // number of times, and a failed inline assert can be swallowed by the + // calling UI operation. + [Test] + [HandlerFunctions('ConfirmHandler')] + procedure PostDocumentConfirmsAndMessages() + begin + // No Initialize(): a value leaked by an earlier test corrupts this one. + RunPostingThatConfirmsAndMessages(); + // No AssertEmpty(): a missing or extra dialog goes unnoticed. + end; + + local procedure RunPostingThatConfirmsAndMessages() + begin + // Raises a Confirm AND a Message, but only ConfirmHandler is listed: + // the Message has nothing to intercept it -> unhandled-UI runtime abort. + if Confirm('Post this document?', false) then + Message('Posting completed.'); + end; + + [ConfirmHandler] + procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean) + begin + // Hardcoded expectation and hardcoded reply. If the wrong dialog fires, + // this inline assert may never surface as the test's verdict. + Assert.AreEqual('Post this document?', Question, 'Wrong confirm.'); + Reply := true; + end; + + var + Assert: Codeunit "Library Assert"; +} diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.good.al b/microsoft/knowledge/testing/ui-handlers-in-tests.good.al new file mode 100644 index 0000000..f955477 --- /dev/null +++ b/microsoft/knowledge/testing/ui-handlers-in-tests.good.al @@ -0,0 +1,57 @@ +codeunit 50400 "Test UI Handlers Good" +{ + Subtype = Test; + + [Test] + [HandlerFunctions('ConfirmHandler,PostMessageHandler')] + procedure PostDocumentConfirmsAndMessages() + begin + Initialize(); + + // [GIVEN] the test enqueues, in interaction order, what each handler + // will see and how it should answer: the Confirm's expected + // question plus the reply to return, then the expected Message. + LibraryVariableStorage.Enqueue('Post this document?'); // expected question (substring) + LibraryVariableStorage.Enqueue(true); // reply ConfirmHandler returns + LibraryVariableStorage.Enqueue('Posting completed.'); // expected message (substring) + + // [WHEN] the code under test raises the Confirm and then the Message + RunPostingThatConfirmsAndMessages(); + + // [THEN] every enqueued expectation was consumed exactly once + LibraryVariableStorage.AssertEmpty(); + end; + + local procedure Initialize() + begin + // Clear leftover values so a value leaked by an earlier test cannot + // cascade into this one. + LibraryVariableStorage.Clear(); + end; + + local procedure RunPostingThatConfirmsAndMessages() + begin + // Stands in for the production routine that confirms, then messages. + if Confirm('Post this document?', false) then + Message('Posting completed.'); + end; + + [ConfirmHandler] + procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean) + begin + // Verify the RIGHT dialog fired (substring match), then return the + // reply the test enqueued for it. + Assert.ExpectedConfirm(LibraryVariableStorage.DequeueText(), Question); + Reply := LibraryVariableStorage.DequeueBoolean(); + end; + + [MessageHandler] + procedure PostMessageHandler(Message: Text[1024]) + begin + Assert.ExpectedMessage(LibraryVariableStorage.DequeueText(), Message); + end; + + var + Assert: Codeunit "Library Assert"; + LibraryVariableStorage: Codeunit "Library - Variable Storage"; +} diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.md b/microsoft/knowledge/testing/ui-handlers-in-tests.md new file mode 100644 index 0000000..338e9ec --- /dev/null +++ b/microsoft/knowledge/testing/ui-handlers-in-tests.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: testing +keywords: [handler, handlerfunctions, confirm, message, strmenu, variable-storage, enqueue, unhandled-ui] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Wire and verify UI handlers with enqueue-driven expectations + +## Description + +A test runs headless: there is no interactive user to answer a dialog. Every UI call the executed path raises — `Confirm`, `Message`, error dialogs, `Page.Run`/`RunModal`, `Report.Run`/`RunModal`, request pages, `StrMenu`, `Notification.Send` — must be intercepted by a handler carrying the matching attribute (`[ConfirmHandler]`, `[MessageHandler]`, `[StrMenuHandler]`, `[ModalPageHandler]`, …) and named in the method's `[HandlerFunctions(...)]`. The list is a two-sided contract: raise a UI call with no listed handler and the platform aborts with an *unhandled UI* error; list a handler the path never hits and it fails with *"handler function was not executed"*. Both are runtime failures — the test never reaches its verdict, so a reviewer sees an infrastructure error instead of a result on the behavior under test. + +Getting the handler *present* is only half the job; the handler must also verify the *right* dialog fired the *right* number of times. Do that by driving handlers from the test, not by hardcoding answers inside them. + +## Best Practice + +Make the test own the expectations and the handlers consume them. Before acting, the test `Enqueue`s — in interaction order — the expected text (a stable substring) and any reply each handler must return. The handler `Dequeue`s the expected text, verifies it with the purpose-built asserts (`Assert.ExpectedMessage`, `Assert.ExpectedConfirm`, `Assert.ExpectedStrMenu` — which match on a fragment, not the full localized caption), then `Dequeue`s and returns its reply. Finish the test body with `LibraryVariableStorage.AssertEmpty` to prove every enqueued interaction fired exactly once, and start each test with an `Initialize` that calls `LibraryVariableStorage.Clear` so a value leaked by an earlier test cannot cascade. List in `[HandlerFunctions]` precisely the handlers the scenario triggers — no superset "just in case", no subset that happens to work today. + +See sample: `ui-handlers-in-tests.good.al`. + +## Anti Pattern + +Omitting a handler for a UI call the path raises (unhandled-UI abort), padding the list with a handler the path never reaches ("handler function was not executed"), or writing handlers that hardcode their answer and assert inline with no enqueue/dequeue. The last is the subtle one: nothing proves the correct dialog fired the expected number of times, and an inline assertion that fails inside a handler can be swallowed by the calling UI operation, leaving the suite green while the behavior is broken. Skipping `Initialize`/`AssertEmpty` hides both a leaked queue and a missing or extra dialog. + +See sample: `ui-handlers-in-tests.bad.al`. diff --git a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.bad.al b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.bad.al new file mode 100644 index 0000000..cf7805a --- /dev/null +++ b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.bad.al @@ -0,0 +1,25 @@ +codeunit 50411 "Test Library Fixtures Bad" +{ + Subtype = Test; + + [Test] + procedure OrderUsesHandRolledFixtures() + var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + begin + // Hand-rolled customer: a chosen "No." with no number-series entry and + // none of the mandatory fields a real customer carries. Bypasses the + // setup production code assumes and breaks when the schema adds a + // required field this test does not set. + Customer.Init(); + Customer."No." := 'X'; + Customer.Insert(); + + SalesHeader.Init(); + SalesHeader."Document Type" := SalesHeader."Document Type"::Order; + SalesHeader."No." := 'SO-X'; + SalesHeader.Validate("Sell-to Customer No.", Customer."No."); + SalesHeader.Insert(true); + end; +} diff --git a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.good.al b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.good.al new file mode 100644 index 0000000..66d4b32 --- /dev/null +++ b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.good.al @@ -0,0 +1,28 @@ +codeunit 50410 "Test Library Fixtures Good" +{ + Subtype = Test; + + [Test] + procedure OrderUsesLibraryCreatedFixtures() + var + Customer: Record Customer; + Item: Record Item; + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + begin + // Library codeunits create valid parents: number series, mandatory + // fields and table relations are all handled for you. + LibrarySales.CreateCustomer(Customer); + LibraryInventory.CreateItem(Item); + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, Customer."No."); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", LibraryRandom.RandInt(10)); + + Assert.AreEqual(Customer."No.", SalesHeader."Sell-to Customer No.", 'Header should use the created customer.'); + end; + + var + Assert: Codeunit "Library Assert"; + LibrarySales: Codeunit "Library - Sales"; + LibraryInventory: Codeunit "Library - Inventory"; + LibraryRandom: Codeunit "Library - Random"; +} diff --git a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md new file mode 100644 index 0000000..270a146 --- /dev/null +++ b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: testing +keywords: [library-codeunits, fixtures, test-data, number-series, prerequisite] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Build fixtures with the test Library codeunits, not hand-rolled Init/Insert + +## Description + +BC ships a layer of test Library codeunits — `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom` and many more — whose job is to create valid records. `CreateCustomer` assigns a number from the customer number series, fills the mandatory fields, and satisfies the table relations the platform enforces; `CreateItem` does the same for items. Hand-rolling `Customer.Init`/`Customer.Insert` with invented values skips the number series and any field a future app version adds as mandatory, so the fixture is invalid the moment it is created and rots silently as the schema evolves. The library codeunits also encode fixture *ordering*: because a `TableRelation` field is checked on `Validate` and `Insert(true)`, every parent a foreign key points to must already exist when the dependent record is built. Assemble fixtures top-down — customer and item before the sales line that references them — or the relation check aborts the test at runtime with a data error rather than an assertion. Prefer the Library codeunits for prerequisite data: they encode the setup the platform requires and are maintained alongside the base app. + +## Best Practice + +Reach for the matching Library codeunit before writing manual record setup: `LibrarySales.CreateCustomer`, `LibrarySales.CreateSalesHeader`/`CreateSalesLine`, `LibraryInventory.CreateItem`, `LibraryERM.CreateGLAccount`, and `LibraryRandom.RandInt`/`RandDec` for values. Create the prerequisite parents first and reference their primary keys from dependent records, and `Validate` the foreign-key field so the `TableRelation` — and any field-validation logic — runs exactly as it would in production. Pass the records they return into the code under test. The fixtures stay valid across upgrades because the library — not your test — owns the knowledge of what a well-formed record requires. + +See sample: `use-library-codeunits-for-test-fixtures.good.al`. + +## Anti Pattern + +`Customer.Init(); Customer."No." := 'X'; Customer.Insert();` — a record with a hand-picked primary key, no number-series entry, and none of the mandatory fields a real customer needs. It compiles and may even insert, but it bypasses setup the production code assumes, and it breaks the first time the schema gains a required field the test does not know about. + +See sample: `use-library-codeunits-for-test-fixtures.bad.al`. From 34c931e1c1a3b90e814a567dadd316aafb321d83 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Mon, 13 Jul 2026 10:34:23 +0200 Subject: [PATCH 10/86] Fix knowledge corpus integrity issues (#87) Repair broken knowledge references and align review examples with canonical articles. Correct explicit version gates, restore a missing title, and recognize the plugin directory in the root guard. Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/flag-new-top-level.yml | 2 +- ...grant-rights-beyond-a-users-entitlement.md | 2 - .../abouttitle-abouttext-teaching-tips.md | 2 +- .../style/this-keyword-in-codeunits.md | 2 +- .../ui/set-selection-filter-list-scope.md | 2 + .../upgrade/datatransfer-for-bulk-init.md | 2 +- ...transfer-skips-triggers-and-subscribers.md | 2 +- microsoft/skills/review/al-code-review.md | 53 +++++++++---------- .../skills/review/al-performance-review.md | 13 +++-- microsoft/skills/review/al-privacy-review.md | 9 ++-- microsoft/skills/review/al-security-review.md | 15 +++--- microsoft/skills/review/al-style-review.md | 5 +- microsoft/skills/review/al-ui-review.md | 7 ++- microsoft/skills/review/al-upgrade-review.md | 5 +- skills/read.md | 2 +- 15 files changed, 58 insertions(+), 65 deletions(-) diff --git a/.github/workflows/flag-new-top-level.yml b/.github/workflows/flag-new-top-level.yml index 4d5f1d7..c3a5c2d 100644 --- a/.github/workflows/flag-new-top-level.yml +++ b/.github/workflows/flag-new-top-level.yml @@ -40,7 +40,7 @@ jobs: // Known, intended repository root. Anything else added at the root // is flagged for a human to eyeball. const ALLOWED_DIRS = new Set([ - '.github', 'community', 'custom', 'microsoft', 'skills', 'tools', + '.claude-plugin', '.github', 'community', 'custom', 'microsoft', 'skills', 'tools', ]); const ALLOWED_FILES = new Set([ '.gitignore', 'CODEOWNERS', 'LICENSE', 'README.md', 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 5334ab7..387c778 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 @@ -19,8 +19,6 @@ Entitlements are license-level caps on what a user can access, derived automatic When designing a permission set that ships with an extension, consult the entitlement model for the target user population before finalizing the grants. Every object and tabledata right the set expects to grant should be reachable within the intended entitlement tier; if it is not, the set needs to be scoped to licenses that permit it, or the feature needs a different access path. -See sample: `do-not-grant-rights-beyond-a-users-entitlement.good.al`. - ## Anti Pattern Authoring permission sets in a sandbox with full-license context and shipping them without verifying which entitlement tier customer users actually hold. The sets look complete in test; on a real customer they silently lose rights at runtime and the symptom is "the feature does not work for some users" with no obvious authorization error. diff --git a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md index f72b959..edefcb6 100644 --- a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md +++ b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [21..] domain: style keywords: [abouttitle, abouttext, teaching-tip, onboarding, page] technologies: [al] diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.md b/microsoft/knowledge/style/this-keyword-in-codeunits.md index ffcf5a1..b38cc24 100644 --- a/microsoft/knowledge/style/this-keyword-in-codeunits.md +++ b/microsoft/knowledge/style/this-keyword-in-codeunits.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [25..] domain: style keywords: [this, codeunit, self-reference, aa0248, scope] technologies: [al] diff --git a/microsoft/knowledge/ui/set-selection-filter-list-scope.md b/microsoft/knowledge/ui/set-selection-filter-list-scope.md index 59bde40..513590c 100644 --- a/microsoft/knowledge/ui/set-selection-filter-list-scope.md +++ b/microsoft/knowledge/ui/set-selection-filter-list-scope.md @@ -7,6 +7,8 @@ countries: [w1] application-area: [all] --- +# Preserve list scope after `SetSelectionFilter` + ## Description `CurrPage.SetSelectionFilter(Rec)` behaves differently depending on whether the user explicitly multi-selected rows. When no rows are marked — the cursor is simply positioned on a row — the method writes a primary key filter for that single row and leaves `MarkedOnly` as false. When the user explicitly selected multiple rows, the method marks those records and sets `MarkedOnly` to true. A batch action that calls `SetSelectionFilter` and then passes the record directly to a processing codeunit will therefore silently restrict to one row whenever the user has not made an explicit selection, which is almost never the intended behaviour for an action labelled "Verify All" or "Post All". diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md index 3eeaa46..988dfa4 100644 --- a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md +++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [21..] domain: upgrade keywords: [datatransfer, large-dataset, bulk-update, modifyall, copyfields, new-field] technologies: [al] diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md index 785684f..49d9ab1 100644 --- a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [21..] domain: upgrade keywords: [datatransfer, validate-trigger, event-subscriber, side-effects, business-logic] technologies: [al] diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index ba58d70..3ea0356 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -127,36 +127,36 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip }, "findings": [ { - "id": "microsoft/knowledge/performance/filter-before-find.md", + "id": "microsoft/knowledge/performance/apply-filters-before-iterating.md", "severity": "major", - "message": "FindSet is called on a record variable without any prior SetRange/SetFilter. This forces a full-table scan.", + "message": "The Country/Region Code predicate is evaluated inside the loop instead of with SetRange before FindSet, so every row crosses the database boundary.", "location": { "file": "src/Sales/PostingRoutines.Codeunit.al", "line": 140, "range": { "start-line": 140, "end-line": 144 } }, "references": [ - { "path": "microsoft/knowledge/performance/filter-before-find.md" } + { "path": "microsoft/knowledge/performance/apply-filters-before-iterating.md" } ], "confidence": "high", "from-sub-skill": "al-performance-review" }, { - "id": "community/knowledge/performance/call-setloadfields-before-filters.md", + "id": "community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.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.", + "message": "The loop reads an unlisted field after SetLoadFields, triggering a hidden JIT load for each record passed by value.", "location": { "file": "src/Sales/PostingRoutines.Codeunit.al", "line": 152 }, "references": [ - { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" } + { "path": "community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md" } ], "confidence": "high", "from-sub-skill": "al-performance-review" }, { - "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md", + "id": "microsoft/knowledge/security/secrettext-for-credentials.md", "severity": "blocker", "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": { @@ -165,21 +165,21 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "range": { "start-line": 85, "end-line": 89 } }, "references": [ - { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" } + { "path": "microsoft/knowledge/security/secrettext-for-credentials.md" } ], "confidence": "high", "from-sub-skill": "al-security-review" }, { - "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md", + "id": "community/knowledge/security/secrets-isolated-storage.md", "severity": "minor", - "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.", + "message": "A setup table stores an API key in an ordinary Text field, exposing it through table reads and exports. Persist it in IsolatedStorage instead.", "location": { - "file": "src/Integration/ApiClient.Codeunit.al", - "line": 201 + "file": "src/Integration/ExternalServiceSetup.Table.al", + "line": 12 }, "references": [ - { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" } + { "path": "community/knowledge/security/secrets-isolated-storage.md" } ], "confidence": "medium", "from-sub-skill": "al-security-review" @@ -209,29 +209,29 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip }, "findings": [ { - "id": "microsoft/knowledge/performance/filter-before-find.md", + "id": "microsoft/knowledge/performance/apply-filters-before-iterating.md", "severity": "major", - "message": "FindSet is called on a record variable without any prior SetRange/SetFilter. This forces a full-table scan.", + "message": "The Country/Region Code predicate is evaluated inside the loop instead of with SetRange before FindSet, so every row crosses the database boundary.", "location": { "file": "src/Sales/PostingRoutines.Codeunit.al", "line": 140, "range": { "start-line": 140, "end-line": 144 } }, "references": [ - { "path": "microsoft/knowledge/performance/filter-before-find.md" } + { "path": "microsoft/knowledge/performance/apply-filters-before-iterating.md" } ], "confidence": "high" }, { - "id": "community/knowledge/performance/call-setloadfields-before-filters.md", + "id": "community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.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.", + "message": "The loop reads an unlisted field after SetLoadFields, triggering a hidden JIT load for each record passed by value.", "location": { "file": "src/Sales/PostingRoutines.Codeunit.al", "line": 152 }, "references": [ - { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" } + { "path": "community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md" } ], "confidence": "high" } @@ -247,7 +247,7 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip }, "findings": [ { - "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md", + "id": "microsoft/knowledge/security/secrettext-for-credentials.md", "severity": "blocker", "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": { @@ -256,20 +256,20 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "range": { "start-line": 85, "end-line": 89 } }, "references": [ - { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" } + { "path": "microsoft/knowledge/security/secrettext-for-credentials.md" } ], "confidence": "high" }, { - "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md", + "id": "community/knowledge/security/secrets-isolated-storage.md", "severity": "minor", - "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.", + "message": "A setup table stores an API key in an ordinary Text field, exposing it through table reads and exports. Persist it in IsolatedStorage instead.", "location": { - "file": "src/Integration/ApiClient.Codeunit.al", - "line": 201 + "file": "src/Integration/ExternalServiceSetup.Table.al", + "line": 12 }, "references": [ - { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" } + { "path": "community/knowledge/security/secrets-isolated-storage.md" } ], "confidence": "medium" } @@ -310,4 +310,3 @@ The empty-corpus case — BCQuality's state until knowledge files land — rolls ] } ``` - diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index 09bef27..5b92d2b 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -89,29 +89,29 @@ Output conforms to the DO output contract. A populated example: }, "findings": [ { - "id": "microsoft/knowledge/performance/filter-before-find.md", + "id": "microsoft/knowledge/performance/apply-filters-before-iterating.md", "severity": "major", - "message": "FindSet is called on a record variable without any prior SetRange/SetFilter. This forces a full-table scan.", + "message": "The Country/Region Code predicate is evaluated inside the loop instead of with SetRange before FindSet, so every row crosses the database boundary.", "location": { "file": "src/Sales/PostingRoutines.Codeunit.al", "line": 140, "range": { "start-line": 140, "end-line": 144 } }, "references": [ - { "path": "microsoft/knowledge/performance/filter-before-find.md" } + { "path": "microsoft/knowledge/performance/apply-filters-before-iterating.md" } ], "confidence": "high" }, { - "id": "community/knowledge/performance/call-setloadfields-before-filters.md", + "id": "community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.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.", + "message": "The loop reads an unlisted field after SetLoadFields, triggering a hidden JIT load for each record passed by value.", "location": { "file": "src/Sales/PostingRoutines.Codeunit.al", "line": 152 }, "references": [ - { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" } + { "path": "community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md" } ], "confidence": "high" } @@ -134,4 +134,3 @@ The empty-corpus case — BCQuality's state until performance knowledge files la "suppressed": [] } ``` - diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index 4ef87e3..bf60f5d 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -89,16 +89,16 @@ Output conforms to the DO output contract. A populated example: }, "findings": [ { - "id": "microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md", + "id": "microsoft/knowledge/privacy/data-classification-required-on-pii-fields.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.", + "message": "The new Customer E-Mail table field has no DataClassification property, leaving personal data unclassified.", "location": { - "file": "src/Sales/CustomerValidation.Codeunit.al", + "file": "src/Sales/Customer.TableExt.al", "line": 64, "range": { "start-line": 60, "end-line": 64 } }, "references": [ - { "path": "microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md" } + { "path": "microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md" } ], "confidence": "high" } @@ -106,4 +106,3 @@ Output conforms to the DO output contract. A populated example: "suppressed": [] } ``` - diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index 1e72447..1986934 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -89,7 +89,7 @@ Output conforms to the DO output contract. A populated example: }, "findings": [ { - "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md", + "id": "microsoft/knowledge/security/secrettext-for-credentials.md", "severity": "blocker", "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": { @@ -98,20 +98,20 @@ Output conforms to the DO output contract. A populated example: "range": { "start-line": 85, "end-line": 89 } }, "references": [ - { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" } + { "path": "microsoft/knowledge/security/secrettext-for-credentials.md" } ], "confidence": "high" }, { - "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md", + "id": "community/knowledge/security/secrets-isolated-storage.md", "severity": "minor", - "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.", + "message": "A setup table stores an API key in an ordinary Text field, exposing it through table reads and exports. Persist it in IsolatedStorage instead.", "location": { - "file": "src/Integration/ApiClient.Codeunit.al", - "line": 201 + "file": "src/Integration/ExternalServiceSetup.Table.al", + "line": 12 }, "references": [ - { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" } + { "path": "community/knowledge/security/secrets-isolated-storage.md" } ], "confidence": "medium" } @@ -134,4 +134,3 @@ The empty-corpus case — BCQuality's state until security knowledge files land "suppressed": [] } ``` - diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index d926df3..fb15dba 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -87,7 +87,7 @@ Output conforms to the DO output contract. A populated example: }, "findings": [ { - "id": "microsoft/knowledge/style/apply-approved-label-suffixes.md", + "id": "microsoft/knowledge/style/label-suffix-approved-list.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": { @@ -95,7 +95,7 @@ Output conforms to the DO output contract. A populated example: "line": 42 }, "references": [ - { "path": "microsoft/knowledge/style/apply-approved-label-suffixes.md" } + { "path": "microsoft/knowledge/style/label-suffix-approved-list.md" } ], "confidence": "high" } @@ -103,4 +103,3 @@ Output conforms to the DO output contract. A populated example: "suppressed": [] } ``` - diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index ef2e94d..2f99c12 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -87,15 +87,15 @@ Output conforms to the DO output contract. A populated example: }, "findings": [ { - "id": "microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md", + "id": "microsoft/knowledge/ui/show-caption-on-editable-fields.md", "severity": "minor", - "message": "Field ToolTip is a fragment ('Customer name') — missing the 'Specifies' opener and the terminating period the house-style guidance requires.", + "message": "An editable page field sets ShowCaption = false, removing the visible and accessible label. Leave ShowCaption enabled or use a documented exception pattern.", "location": { "file": "src/Sales/CustomerCard.Page.al", "line": 58 }, "references": [ - { "path": "microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md" } + { "path": "microsoft/knowledge/ui/show-caption-on-editable-fields.md" } ], "confidence": "high" } @@ -103,4 +103,3 @@ Output conforms to the DO output contract. A populated example: "suppressed": [] } ``` - diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md index 7ccfa4a..3b91d67 100644 --- a/microsoft/skills/review/al-upgrade-review.md +++ b/microsoft/skills/review/al-upgrade-review.md @@ -89,7 +89,7 @@ Output conforms to the DO output contract. A populated example: }, "findings": [ { - "id": "microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md", + "id": "microsoft/knowledge/upgrade/enum-values-additive-at-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": { @@ -97,7 +97,7 @@ Output conforms to the DO output contract. A populated example: "line": 7 }, "references": [ - { "path": "microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md" } + { "path": "microsoft/knowledge/upgrade/enum-values-additive-at-end.md" } ], "confidence": "high" } @@ -105,4 +105,3 @@ Output conforms to the DO output contract. A populated example: "suppressed": [] } ``` - diff --git a/skills/read.md b/skills/read.md index 8badb97..6a2080d 100644 --- a/skills/read.md +++ b/skills/read.md @@ -115,7 +115,7 @@ Consumers MUST NOT silently treat missing context as a match. ## Citing a knowledge file -A consumer that produces output referencing a knowledge file MUST cite it by its repo-relative path (for example, `microsoft/knowledge/performance/filter-before-find.md`). Line numbers are not stable references; use the file path only. If a commit SHA is available to the consumer, it SHOULD be included alongside the path. +A consumer that produces output referencing a knowledge file MUST cite it by its repo-relative path (for example, `microsoft/knowledge/performance/apply-filters-before-iterating.md`). Line numbers are not stable references; use the file path only. If a commit SHA is available to the consumer, it SHOULD be included alongside the path. ## Sample files From 766046b85deff8f3e94d6364c573b4df99a9c8a4 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Mon, 13 Jul 2026 10:35:31 +0200 Subject: [PATCH 11/86] Add data-modeling and appsource knowledge articles (MICROSOFT layer) (#65) Author 7 remedial BCQuality knowledge articles plus good/bad AL samples (21 files) covering AL master-table and data-model design: - data-modeling: master No. from number series in OnInsert; use codeunit "No. Series" not obsolete NoSeriesManagement; setup table is a singleton; set Last Date Modified in OnModify and OnRename; enforce Blocked in referencing code not in the master. - style: ApplicationArea required on page controls (AS0062). - appsource: object affixes prevent collisions (AS0011). Clean-room authored from own BC knowledge; specifics verified against public sources only (learn.microsoft.com, microsoft/BCApps). Introduces two new domains (data-modeling, appsource). Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../object-affixes-prevent-collisions.bad.al | 43 ++++++++++ .../object-affixes-prevent-collisions.good.al | 40 +++++++++ .../object-affixes-prevent-collisions.md | 28 +++++++ ...d-in-referencing-code-not-in-master.bad.al | 71 ++++++++++++++++ ...-in-referencing-code-not-in-master.good.al | 84 +++++++++++++++++++ ...ocked-in-referencing-code-not-in-master.md | 28 +++++++ ...e-no-from-number-series-in-oninsert.bad.al | 31 +++++++ ...-no-from-number-series-in-oninsert.good.al | 45 ++++++++++ ...table-no-from-number-series-in-oninsert.md | 28 +++++++ ...e-modified-in-onmodify-and-onrename.bad.al | 39 +++++++++ ...-modified-in-onmodify-and-onrename.good.al | 40 +++++++++ ...-date-modified-in-onmodify-and-onrename.md | 28 +++++++ .../setup-table-is-a-singleton.bad.al | 55 ++++++++++++ .../setup-table-is-a-singleton.good.al | 70 ++++++++++++++++ .../setup-table-is-a-singleton.md | 28 +++++++ ...ies-codeunit-not-noseriesmanagement.bad.al | 51 +++++++++++ ...es-codeunit-not-noseriesmanagement.good.al | 55 ++++++++++++ ...-series-codeunit-not-noseriesmanagement.md | 28 +++++++ ...ationarea-required-on-page-controls.bad.al | 25 ++++++ ...tionarea-required-on-page-controls.good.al | 40 +++++++++ ...plicationarea-required-on-page-controls.md | 28 +++++++ 21 files changed, 885 insertions(+) create mode 100644 microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al create mode 100644 microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al create mode 100644 microsoft/knowledge/appsource/object-affixes-prevent-collisions.md create mode 100644 microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.bad.al create mode 100644 microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.good.al create mode 100644 microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md create mode 100644 microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.bad.al create mode 100644 microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.good.al create mode 100644 microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md create mode 100644 microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.bad.al create mode 100644 microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.good.al create mode 100644 microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md create mode 100644 microsoft/knowledge/data-modeling/setup-table-is-a-singleton.bad.al create mode 100644 microsoft/knowledge/data-modeling/setup-table-is-a-singleton.good.al create mode 100644 microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md create mode 100644 microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.bad.al create mode 100644 microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.good.al create mode 100644 microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md create mode 100644 microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al create mode 100644 microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al create mode 100644 microsoft/knowledge/style/applicationarea-required-on-page-controls.md diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al new file mode 100644 index 0000000..dc1c6f3 --- /dev/null +++ b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al @@ -0,0 +1,43 @@ +// Anti-pattern: an own object with no affix. Another app that also defines a +// "Loyalty Tier" table cannot be installed alongside this one. +table 50379 "Loyalty Tier" +{ + Caption = 'Loyalty Tier'; + DataClassification = CustomerContent; + + fields + { + field(1; "Code"; Code[20]) + { + Caption = 'Code'; + } + field(10; Description; Text[100]) + { + Caption = 'Description'; + } + } + + keys + { + key(PK; "Code") + { + Clustered = true; + } + } +} + +// Anti-pattern (the common half-measure): the extension object carries the +// affix, but the field it adds to the standard Customer table does not. That +// unaffixed field still collides with any other app that adds "Loyalty Points" +// to Customer, and AS0011 flags it. +tableextension 50378 "ABC Customer Ext" extends Customer +{ + fields + { + field(50378; "Loyalty Points"; Integer) + { + Caption = 'Loyalty Points'; + DataClassification = CustomerContent; + } + } +} diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al new file mode 100644 index 0000000..28bfa4d --- /dev/null +++ b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al @@ -0,0 +1,40 @@ +// Own object: the affix "ABC" is carried at object-name level. +table 50377 "ABC Loyalty Tier" +{ + Caption = 'Loyalty Tier'; + DataClassification = CustomerContent; + + fields + { + field(1; "Code"; Code[20]) + { + Caption = 'Code'; + } + field(10; Description; Text[100]) + { + Caption = 'Description'; + } + } + + keys + { + key(PK; "Code") + { + Clustered = true; + } + } +} + +// Extension of a standard object: the added field is individually affixed, +// because the object name (Customer) belongs to the base application. +tableextension 50376 "ABC Customer Ext" extends Customer +{ + fields + { + field(50376; "Loyalty Points ABC"; Integer) + { + Caption = 'Loyalty Points'; + DataClassification = CustomerContent; + } + } +} diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md new file mode 100644 index 0000000..49ef80c --- /dev/null +++ b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: appsource +keywords: [object-affix, prefix, suffix, as0011, appsourcecop, collision, tableextension] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Apply a reserved affix to objects and to members added to base objects + +## Description + +An AppSource extension must carry a reserved affix — a prefix or a suffix of at least three characters — on the names of the objects it owns **and** on any field, key, control, or action it adds to a base-application object. The affix is registered with Microsoft; when two coexisting extensions would otherwise collide, the registrant of the affix wins. Without it, two apps that both add a `Loyalty Points` field to `Customer`, or both define a `Loyalty Tier` table, cannot be installed side by side. + +AppSourceCop enforces this. The primary rule is AS0011 ("An affix is required"); the affixes are configured through `mandatoryAffixes` (and `mandatoryPrefix`) in `AppSourceCop.json`. Two placements matter and are easy to get half-right: an object you define carries the affix at **object-name** level, while a member you add to a **standard** object carries the affix on that **member's** name. Adding an affixed object is not enough — an unaffixed field bolted onto `Customer` still collides and still fails validation. + +## Best Practice + +Own objects are named with the affix (e.g. a table `ABC Loyalty Tier`), and every field or action added to a standard object is individually affixed (e.g. `Loyalty Points ABC` on a `Customer` tableextension). + +See sample: `object-affixes-prevent-collisions.good.al`. + +## Anti Pattern + +Unaffixed object or member names, or the common half-measure: the extension object carries the affix but a field it adds to a standard table does not. AS0011 flags the missing affix and the field can still collide with another app. + +See sample: `object-affixes-prevent-collisions.bad.al`. diff --git a/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.bad.al b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.bad.al new file mode 100644 index 0000000..7cf9d52 --- /dev/null +++ b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.bad.al @@ -0,0 +1,71 @@ +table 50372 "Loyalty Member" +{ + Caption = 'Loyalty Member'; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) + { + Caption = 'No.'; + } + field(10; Name; Text[100]) + { + Caption = 'Name'; + } + field(20; Blocked; Boolean) + { + Caption = 'Blocked'; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + } + + // Anti-pattern: the block check sits in the master's own trigger. Editing a + // blocked member is rare; referencing it is constant, and references never + // fire OnModify. So this stops nothing that matters. + trigger OnModify() + begin + TestField(Blocked, false); + end; +} + +table 50373 "Loyalty Point Entry" +{ + Caption = 'Loyalty Point Entry'; + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) + { + Caption = 'Entry No.'; + AutoIncrement = true; + } + field(10; "Member No."; Code[20]) + { + Caption = 'Member No.'; + TableRelation = "Loyalty Member"."No."; + // No block check on the referencing side: a line can freely + // reference a blocked member, and posting proceeds unchecked. + } + field(20; Points; Integer) + { + Caption = 'Points'; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } +} diff --git a/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.good.al b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.good.al new file mode 100644 index 0000000..f76a46a --- /dev/null +++ b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.good.al @@ -0,0 +1,84 @@ +table 50370 "Loyalty Member" +{ + Caption = 'Loyalty Member'; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) + { + Caption = 'No.'; + } + field(10; Name; Text[100]) + { + Caption = 'Name'; + } + // Blocked is inert data here: the master carries the flag but no logic. + field(20; Blocked; Boolean) + { + Caption = 'Blocked'; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + } +} + +table 50371 "Loyalty Point Entry" +{ + Caption = 'Loyalty Point Entry'; + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) + { + Caption = 'Entry No.'; + AutoIncrement = true; + } + field(10; "Member No."; Code[20]) + { + Caption = 'Member No.'; + TableRelation = "Loyalty Member"."No."; + + trigger OnValidate() + var + LoyaltyMember: Record "Loyalty Member"; + begin + if "Member No." = '' then + exit; + // Enforcement lives at the point of use: reject a blocked master + // as soon as a line references it. + LoyaltyMember.Get("Member No."); + LoyaltyMember.TestField(Blocked, false); + end; + } + field(20; Points; Integer) + { + Caption = 'Points'; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } + + procedure Post() + var + LoyaltyMember: Record "Loyalty Member"; + begin + // Re-check before committing the transaction, in case the member was + // blocked after the line was created. + LoyaltyMember.Get("Member No."); + LoyaltyMember.TestField(Blocked, false); + end; +} diff --git a/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md new file mode 100644 index 0000000..e324d45 --- /dev/null +++ b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [blocked-field, testfield, referencing-code, point-of-use, enforcement, journal-line] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Enforce `Blocked` where the master is used, not in the master itself + +## Description + +The `Blocked` field on a master record (`Item`, `Customer`, `Resource`, or a custom master) is inert data. The master table holds **no** logic that acts on it. Enforcement belongs in the **consuming** code: when a journal line, document line, or posting routine references the master by its `No.`, that referencing object tests the flag at the point of use, e.g. `LoyaltyMember.Get("Member No."); LoyaltyMember.TestField(Blocked, false);` in the line's `OnValidate` and again before posting. + +Putting the block check inside the master's own `OnInsert`/`OnModify` does nothing to stop transactional use: a blocked master is edited rarely, but it is *referenced* constantly, and those references never touch the master's own triggers. Base BC follows this split — `Item.Blocked` is checked by sales/purchase/journal code, not by the `Item` table. A boolean `Blocked` uses `TestField(Blocked, false)`; an option-style block (e.g. `Sales`/`All`) needs the specific option compared at each relevant path. + +## Best Practice + +The referencing line validates `Master.TestField(Blocked, false)` in `OnValidate` of the reference field and re-checks before posting. The master table stays logic-free on `Blocked`. + +See sample: `check-blocked-in-referencing-code-not-in-master.good.al`. + +## Anti Pattern + +The block check sits in the master's own `OnModify`/`OnInsert` (so referencing and posting proceed unchecked), or there is no check at all on the referencing side. + +See sample: `check-blocked-in-referencing-code-not-in-master.bad.al`. diff --git a/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.bad.al b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.bad.al new file mode 100644 index 0000000..307b925 --- /dev/null +++ b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.bad.al @@ -0,0 +1,31 @@ +table 50361 "Loyalty Member" +{ + Caption = 'Loyalty Member'; + DataClassification = CustomerContent; + + fields + { + // Anti-pattern: an autoincrement Integer surrogate used as the business key. + field(1; "Entry No."; Integer) + { + Caption = 'Entry No.'; + AutoIncrement = true; + } + field(10; Name; Text[100]) + { + Caption = 'Name'; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } + + // No OnInsert, no number series, no "No." code, and no "No. Series" field. + // Records get an opaque integer users never see and cannot quote on the phone, + // and the master is cut off from BC's standard numbering and manual-entry flow. +} diff --git a/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.good.al b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.good.al new file mode 100644 index 0000000..5bc49d3 --- /dev/null +++ b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.good.al @@ -0,0 +1,45 @@ +table 50360 "Loyalty Member" +{ + Caption = 'Loyalty Member'; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) + { + Caption = 'No.'; + NotBlank = true; + } + field(2; "No. Series"; Code[20]) + { + Caption = 'No. Series'; + Editable = false; + TableRelation = "No. Series"; + } + field(10; Name; Text[100]) + { + Caption = 'Name'; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + } + + trigger OnInsert() + var + LoyaltySetup: Record "Loyalty Setup"; + NoSeries: Codeunit "No. Series"; + begin + if "No." = '' then begin + LoyaltySetup.Get(); + LoyaltySetup.TestField("Member Nos."); + "No. Series" := LoyaltySetup."Member Nos."; + "No." := NoSeries.GetNextNo("No. Series"); + end; + end; +} diff --git a/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md new file mode 100644 index 0000000..f4c6a15 --- /dev/null +++ b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [no-series, primary-key, code20, oninsert, autoincrement, number-assignment] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A master table's `No.` primary key comes from a number series in `OnInsert` + +## Description + +In Business Central, a master table (Customer, Vendor, Item, and any custom equivalent) uses a single primary-key field named `No.` of type `Code[20]`. It is populated from a number series — configured on the feature's application-area setup table — inside the table's `OnInsert` trigger, but only when `No.` is still blank (so a user may still type a manual number when the series allows it). The record also keeps a non-editable `No. Series` `Code[20]` field recording which series produced the number. + +This is not an `Integer` `AutoIncrement` key, a GUID, or the `SystemId`. Those are surrogate/system identifiers that users never see and cannot quote; BC's whole document flow — lookups, filtering, printed references, telephone support — depends on a short, human-readable, business-controlled `No.`. Use the modern assignment API described in `use-no-series-codeunit-not-noseriesmanagement.md`. + +## Best Practice + +`No.` `Code[20]` is the sole primary key; a non-editable `No. Series` `Code[20]` field records the source series. `OnInsert` checks `if "No." = ''`, reads the setup table, `TestField`s the configured series, stores it in `No. Series`, and assigns `No.` from the series. + +See sample: `master-table-no-from-number-series-in-oninsert.good.al`. + +## Anti Pattern + +An `Integer` `AutoIncrement` (or GUID / `SystemId`) primary key used as the business key, with no `OnInsert` number assignment. Records get an opaque identifier no user can reference, and the master no longer participates in the standard numbering and manual-entry behavior every other BC master follows. + +See sample: `master-table-no-from-number-series-in-oninsert.bad.al`. diff --git a/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.bad.al b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.bad.al new file mode 100644 index 0000000..c2031a0 --- /dev/null +++ b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.bad.al @@ -0,0 +1,39 @@ +table 50369 "Loyalty Member" +{ + Caption = 'Loyalty Member'; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) + { + Caption = 'No.'; + } + field(10; Name; Text[100]) + { + Caption = 'Name'; + } + field(20; "Last Date Modified"; Date) + { + Caption = 'Last Date Modified'; + Editable = false; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + } + + trigger OnModify() + begin + "Last Date Modified" := Today(); + end; + + // Missing OnRename: renaming the member changes the primary key without + // firing OnModify, so "Last Date Modified" keeps its old, stale value and + // change-detection logic downstream skips the renamed record. +} diff --git a/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.good.al b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.good.al new file mode 100644 index 0000000..18e1434 --- /dev/null +++ b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.good.al @@ -0,0 +1,40 @@ +table 50368 "Loyalty Member" +{ + Caption = 'Loyalty Member'; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) + { + Caption = 'No.'; + } + field(10; Name; Text[100]) + { + Caption = 'Name'; + } + field(20; "Last Date Modified"; Date) + { + Caption = 'Last Date Modified'; + Editable = false; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + } + + trigger OnModify() + begin + "Last Date Modified" := Today(); + end; + + trigger OnRename() + begin + "Last Date Modified" := Today(); + end; +} diff --git a/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md new file mode 100644 index 0000000..dbc0a64 --- /dev/null +++ b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [last-date-modified, onmodify, onrename, audit-field, non-editable, stale-value] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Refresh `Last Date Modified` in both `OnModify` and `OnRename` + +## Description + +Master tables carry a non-editable `Last Date Modified` field of type `Date`. It records when the record last changed and is refreshed by table triggers, not by the user. The refresh must happen in **both** `OnModify` and `OnRename`. + +The reason is a BC-specific trap: renaming a record changes its primary key and fires `OnRename` — it does **not** fire `OnModify`. A table that updates `Last Date Modified` only in `OnModify` therefore leaves a stale date behind every rename. Downstream logic that keys on this field (incremental sync, integration deltas, "changed since" reports) then silently skips the renamed record. Assign `Today` (the system date), not `WorkDate`, because the field reflects the real modification moment. + +## Best Practice + +Both `OnModify` and `OnRename` set `"Last Date Modified" := Today();`, and the field is declared `Editable = false` so only the triggers maintain it. + +See sample: `set-last-date-modified-in-onmodify-and-onrename.good.al`. + +## Anti Pattern + +Only `OnModify` assigns `Last Date Modified`. After a rename the value is stale, and any process that trusts it to detect changes misses the record. + +See sample: `set-last-date-modified-in-onmodify-and-onrename.bad.al`. diff --git a/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.bad.al b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.bad.al new file mode 100644 index 0000000..c6585c8 --- /dev/null +++ b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.bad.al @@ -0,0 +1,55 @@ +table 50366 "Loyalty Setup" +{ + Caption = 'Loyalty Setup'; + DataClassification = CustomerContent; + + fields + { + // Anti-pattern: an autoincrement key lets the table hold many rows, + // so "the setup" is no longer a single, well-known record. + field(1; "Entry No."; Integer) + { + Caption = 'Entry No.'; + AutoIncrement = true; + } + field(10; "Member Nos."; Code[20]) + { + Caption = 'Member Nos.'; + TableRelation = "No. Series"; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } +} + +page 50367 "Loyalty Setup List" +{ + // Anti-pattern: a List page over a setup table invites multiple rows and + // never guarantees that a row exists to read. + Caption = 'Loyalty Setup List'; + PageType = List; + SourceTable = "Loyalty Setup"; + UsageCategory = Administration; + ApplicationArea = All; + + layout + { + area(Content) + { + repeater(Group) + { + field("Member Nos."; Rec."Member Nos.") + { + ApplicationArea = All; + ToolTip = 'Specifies the number series used to assign member numbers.'; + } + } + } + } +} diff --git a/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.good.al b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.good.al new file mode 100644 index 0000000..d299810 --- /dev/null +++ b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.good.al @@ -0,0 +1,70 @@ +table 50364 "Loyalty Setup" +{ + Caption = 'Loyalty Setup'; + DataClassification = CustomerContent; + + fields + { + field(1; "Primary Key"; Code[10]) + { + Caption = 'Primary Key'; + } + field(10; "Member Nos."; Code[20]) + { + Caption = 'Member Nos.'; + TableRelation = "No. Series"; + } + } + + keys + { + key(PK; "Primary Key") + { + Clustered = true; + } + } + + procedure GetRecordOnce() + begin + if Rec.Get() then + exit; + Rec.Init(); + Rec.Insert(); + end; +} + +page 50365 "Loyalty Setup" +{ + Caption = 'Loyalty Setup'; + PageType = Card; + SourceTable = "Loyalty Setup"; + UsageCategory = Administration; + ApplicationArea = All; + InsertAllowed = false; + DeleteAllowed = false; + + layout + { + area(Content) + { + group(Numbering) + { + Caption = 'Numbering'; + field("Member Nos."; Rec."Member Nos.") + { + ApplicationArea = All; + ToolTip = 'Specifies the number series used to assign member numbers.'; + } + } + } + } + + trigger OnOpenPage() + begin + Rec.Reset(); + if not Rec.Get() then begin + Rec.Init(); + Rec.Insert(); + end; + end; +} diff --git a/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md new file mode 100644 index 0000000..774963f --- /dev/null +++ b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [setup-table, insertallowed, deleteallowed, getrecordonce, primary-key, card-page] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A setup table is a singleton: one blank-keyed row, no insert or delete + +## Description + +An application-area setup table (`Sales & Receivables Setup`, `Inventory Setup`, and any custom `* Setup`) holds exactly one record per company. Its primary key is a single `Code[10]` field named `Primary Key`, and the row's value is left blank. Nothing else identifies the row — there is only ever one. + +The setup **card** page enforces the singleton: `InsertAllowed = false` and `DeleteAllowed = false` stop a second row or an empty table, and the page guarantees the row exists on first open — typically `OnOpenPage` with `if not Rec.Get() then begin Rec.Init(); Rec.Insert(); end;`, or a `GetRecordOnce` helper on the table. Consuming code then reads it with a plain `Get()`. The read side needs no access optimization — see `singleton-setup-tables-need-no-access-optimization.md`. + +## Best Practice + +`Primary Key` `Code[10]` is the sole key; the setup is surfaced through a Card page with `InsertAllowed = false`, `DeleteAllowed = false`, and an open-time guard that inserts the blank row if it is missing. + +See sample: `setup-table-is-a-singleton.good.al`. + +## Anti Pattern + +An `Integer` / `AutoIncrement` key, a page that allows insert or delete, or a List page over the setup table. Any of these lets the table hold zero or many rows, so "the setup" becomes ambiguous and `Get()` may fail or read the wrong record. + +See sample: `setup-table-is-a-singleton.bad.al`. diff --git a/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.bad.al b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.bad.al new file mode 100644 index 0000000..cfac379 --- /dev/null +++ b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.bad.al @@ -0,0 +1,51 @@ +table 50363 "Loyalty Member" +{ + Caption = 'Loyalty Member'; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) + { + Caption = 'No.'; + + trigger OnValidate() + begin + if "No." = xRec."No." then + exit; + LoyaltySetup.Get(); + // Obsolete-pending: NoSeriesManagement.TestManual raises a + // deprecation warning and is scheduled for removal. + NoSeriesMgt.TestManual(LoyaltySetup."Member Nos."); + "No. Series" := ''; + end; + } + field(2; "No. Series"; Code[20]) + { + Caption = 'No. Series'; + Editable = false; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + } + + var + LoyaltySetup: Record "Loyalty Setup"; + NoSeriesMgt: Codeunit NoSeriesManagement; + + trigger OnInsert() + begin + if "No." = '' then begin + LoyaltySetup.Get(); + LoyaltySetup.TestField("Member Nos."); + // Obsolete-pending legacy assignment call; use codeunit "No. Series". + NoSeriesMgt.InitSeries(LoyaltySetup."Member Nos.", xRec."No. Series", 0D, "No.", "No. Series"); + end; + end; +} diff --git a/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.good.al b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.good.al new file mode 100644 index 0000000..debe070 --- /dev/null +++ b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.good.al @@ -0,0 +1,55 @@ +table 50362 "Loyalty Member" +{ + Caption = 'Loyalty Member'; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) + { + Caption = 'No.'; + + trigger OnValidate() + var + NoSeries: Codeunit "No. Series"; + begin + if "No." = xRec."No." then + exit; + LoyaltySetup.Get(); + if not NoSeries.IsManual(LoyaltySetup."Member Nos.") then + Error(ManualNosNotAllowedErr); + "No. Series" := ''; + end; + } + field(2; "No. Series"; Code[20]) + { + Caption = 'No. Series'; + Editable = false; + TableRelation = "No. Series"; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + } + + var + LoyaltySetup: Record "Loyalty Setup"; + ManualNosNotAllowedErr: Label 'Numbers are assigned automatically. Allow manual numbers on the No. Series to enter one by hand.'; + + trigger OnInsert() + var + NoSeries: Codeunit "No. Series"; + begin + if "No." = '' then begin + LoyaltySetup.Get(); + LoyaltySetup.TestField("Member Nos."); + "No. Series" := LoyaltySetup."Member Nos."; + "No." := NoSeries.GetNextNo("No. Series"); + end; + end; +} diff --git a/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md new file mode 100644 index 0000000..b4d19b9 --- /dev/null +++ b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md @@ -0,0 +1,28 @@ +--- +bc-version: [22..] +domain: data-modeling +keywords: [no-series, getnextno, ismanual, noseriesmanagement, obsolete-pending, testmanual] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Assign numbers with codeunit `"No. Series"`, not the obsolete `NoSeriesManagement` + +## Description + +Since 2023 release wave 1 (v22) the number-series API is codeunit **310** `"No. Series"`, called by name in AL. Its methods include `GetNextNo`, `PeekNextNo`, `IsManual`, `TestManual`, and `LookupRelatedNoSeries`. The older codeunit **396** `NoSeriesManagement` and its `InitSeries` / `SelectSeries` / `SetSeries` / `TestManual` methods are marked obsolete-pending: they still compile but raise a deprecation warning and are scheduled for removal, so they must not appear in new code. + +LLMs reproduce the legacy `NoSeriesManagement` pattern because it dominates pre-2023 training data. Prefer the new codeunit: it has a cleaner surface and is the only version that survives the deprecation. (The numbers matter — `310` is the current codeunit; `396` is the legacy one being retired.) Verify signatures on learn.microsoft.com or in the `microsoft/BCApps` source before use. + +## Best Practice + +`OnInsert` assigns the number with `NoSeries.GetNextNo("No. Series")` where `NoSeries` is `Codeunit "No. Series"`. The `No.` field's `OnValidate` guards manual entry by calling `NoSeries.IsManual(...)` (or `TestManual`) before clearing `No. Series`. + +See sample: `use-no-series-codeunit-not-noseriesmanagement.good.al`. + +## Anti Pattern + +`NoSeriesMgt.InitSeries(...)` for assignment and `NoSeriesMgt.TestManual(...)` for the manual check, where `NoSeriesMgt` is `Codeunit NoSeriesManagement`. Both are obsolete-pending and emit compiler warnings. + +See sample: `use-no-series-codeunit-not-noseriesmanagement.bad.al`. diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al b/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al new file mode 100644 index 0000000..8da7777 --- /dev/null +++ b/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al @@ -0,0 +1,25 @@ +page 50375 "Sample App Area Bad" +{ + PageType = Card; + SourceTable = Customer; + layout + { + area(Content) + { + group(General) + { + // Anti-pattern: no ApplicationArea. AS0062 flags this control, + // and it is silently hidden in the Web client for profiles whose + // enabled areas do not already cover it. + field("No."; Rec."No.") + { + ToolTip = 'Specifies the number that identifies the customer.'; + } + field(Name; Rec.Name) + { + ToolTip = 'Specifies the customer''s name.'; + } + } + } + } +} diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al b/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al new file mode 100644 index 0000000..d344337 --- /dev/null +++ b/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al @@ -0,0 +1,40 @@ +page 50374 "Sample App Area Good" +{ + PageType = Card; + SourceTable = Customer; + layout + { + area(Content) + { + group(General) + { + field("No."; Rec."No.") + { + ApplicationArea = All; + ToolTip = 'Specifies the number that identifies the customer.'; + } + field(Name; Rec.Name) + { + ApplicationArea = All; + ToolTip = 'Specifies the customer''s name.'; + } + } + } + } + actions + { + area(Processing) + { + action(Refresh) + { + ApplicationArea = All; + ToolTip = 'Reloads the current record.'; + + trigger OnAction() + begin + CurrPage.Update(false); + end; + } + } + } +} diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.md b/microsoft/knowledge/style/applicationarea-required-on-page-controls.md new file mode 100644 index 0000000..606e08a --- /dev/null +++ b/microsoft/knowledge/style/applicationarea-required-on-page-controls.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [application-area, page-control, as0062, appsourcecop, hidden-control, web-client] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every page control needs an `ApplicationArea` (AppSourceCop AS0062) + +## Description + +A field control on a page or pageextension that has no `ApplicationArea` property is silently hidden in the Web client for every profile whose enabled application areas do not cover it. There is no error and no warning at runtime — the field simply does not appear, which reads as data loss to the user. AppSourceCop AS0062 flags any page control or action that is missing the `ApplicationArea` property, and AppSource technical validation rejects the app until it is set. + +Set the property to an area the app actually enables. `All` makes the control visible under every profile and is the common default; if the app declares narrower areas in `app.json`, use one of those. The property applies to field controls and to actions. This is a sibling concern to `caption-required-on-page-fields.md` and `tooltip-required-on-page-fields.md`; note that the ToolTip requirement is the separate CodeCop rule AA0218, not AS0062. + +## Best Practice + +Every field control and action carries `ApplicationArea = All;` (or a declared area of the app). The value is set once per control and keeps the control visible in the Web client. + +See sample: `applicationarea-required-on-page-controls.good.al`. + +## Anti Pattern + +A field control with no `ApplicationArea`. AS0062 flags it, and the control is invisible in the Web client for any profile that does not already enable a matching area. + +See sample: `applicationarea-required-on-page-controls.bad.al`. From 7a678d1affb28b48ce7c25f06d5325fcd05726ab Mon Sep 17 00:00:00 2001 From: Wenjie Fan <31087545+gggdttt@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:56:52 +0200 Subject: [PATCH 12/86] Add monthly manual versioning (major.minor VERSION + release workflow) (#88) * Add monthly manual versioning: VERSION file + release-version workflow * Version as major.minor (1.0); bump minor monthly, major for breaking * Clarify BCQuality minor is monotonic and never resets across major bumps * Align comment with engine X.Y.Z scheme (minor = Z) * Document BCQuality versioning in README * Drop PRReviewAgent reference from README versioning section * Derive release version from git tags, drop VERSION file --------- Co-authored-by: wenjiefan --- .github/workflows/release-version.yml | 69 +++++++++++++++++++++++++++ README.md | 12 +++++ 2 files changed, 81 insertions(+) create mode 100644 .github/workflows/release-version.yml diff --git a/.github/workflows/release-version.yml b/.github/workflows/release-version.yml new file mode 100644 index 0000000..36f80f3 --- /dev/null +++ b/.github/workflows/release-version.yml @@ -0,0 +1,69 @@ +# Cuts a BCQuality content release on demand (roughly monthly), NOT on every +# commit. Run this workflow manually once the `main` content is ready, and choose +# whether to bump the minor (usual periodic content update) or the major +# (breaking change). +# +# The version is a `major.minor` value derived from existing git tags — there is +# no VERSION file. The minor is a monotonic counter: it only ever increments and +# never resets, even across a major bump, so it uniquely identifies a release. +# This workflow computes the next version and tags the current commit as +# `v{major}.{minor}`. + +name: Release version + +on: + workflow_dispatch: + inputs: + bump: + description: Which part to bump + type: choice + options: + - minor + - major + default: minor + +# Only tag creation needs write. +permissions: + contents: write + +concurrency: + group: release-version + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Compute and tag release + shell: bash + run: | + git fetch --tags --force --quiet + tags="$(git tag -l | grep -E '^v[0-9]+\.[0-9]+$' || true)" + + if [[ -z "$tags" ]]; then + # First release. + major=1 + minor=0 + else + latest_major="$(printf '%s\n' "$tags" | sed -E 's/^v([0-9]+)\..*/\1/' | sort -n | tail -1)" + latest_minor="$(printf '%s\n' "$tags" | sed -E 's/^v[0-9]+\.([0-9]+)$/\1/' | sort -n | tail -1)" + minor=$(( latest_minor + 1 )) # monotonic, never resets + if [[ "${{ inputs.bump }}" == "major" ]]; then + major=$(( latest_major + 1 )) + else + major="$latest_major" + fi + fi + + tag="v${major}.${minor}" + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "::error::Tag ${tag} already exists" + exit 1 + fi + git tag "$tag" "${{ github.sha }}" + git push origin "$tag" + echo "Released BCQuality ${tag} at ${{ github.sha }}" diff --git a/README.md b/README.md index ddab523..6abf98a 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,18 @@ For the end-to-end flow — from orchestrator trigger through to how output reac │ └── /skills/ ``` +## Versioning + +BCQuality content is released on demand — roughly monthly, not on every commit. A +release is a `major.minor` value derived from git tags, cut manually via the +`Release version` workflow: pick whether to bump the minor or the major, and it +computes the next version and tags the current `main` as `v{major}.{minor}`. + +- Bump the **minor** for the usual periodic content update; bump the **major** + only for a breaking change. +- The minor is a **monotonic counter** — it only ever increments and never + resets, even across a major bump — so it uniquely identifies a release. + ## Contributing Contributions are welcome. Before submitting a PR: From bfda67a95a3787801074310afa8404ce021c7766 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 14 Jul 2026 11:23:57 +0200 Subject: [PATCH 13/86] Promote security knowledge from community to Microsoft layer (#49) * Promote security knowledge from community to Microsoft layer Pure git-mv relocation of the SECURITY domain from the community layer to the Microsoft layer. No content changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback on security knowledge promotion - do-not-grant-rights-beyond-a-users-entitlement.md: drop the See sample reference to a .good.al file that does not exist - Remove the 'Contributions welcome' boilerplate line from compose-permission-sets, prefer-oauth2, and protect-sensitive-data - protect-sensitive-data-in-temporary-tables: remove the pointless DeleteAll on the locally scoped temp buffer in the good sample and reword Best Practice to note local buffers are cleaned up automatically - Drop guard-bulk-operations-with-istemporary from the promotion; it stays in the community layer pending a decision on whether it is security Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Remove Contributions welcome boilerplate from do-not-grant article for consistency Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../classify-every-field-with-dataclassification.bad.al | 0 .../classify-every-field-with-dataclassification.good.al | 0 .../security/classify-every-field-with-dataclassification.md | 0 .../compose-permission-sets-with-included-sets.bad.al | 0 .../compose-permission-sets-with-included-sets.good.al | 0 .../security/compose-permission-sets-with-included-sets.md | 2 -- .../do-not-grant-rights-beyond-a-users-entitlement.md | 2 -- ...prefer-oauth2-over-api-keys-for-external-http-calls.bad.al | 0 ...refer-oauth2-over-api-keys-for-external-http-calls.good.al | 0 .../prefer-oauth2-over-api-keys-for-external-http-calls.md | 2 -- .../protect-sensitive-data-in-temporary-tables.bad.al | 0 .../protect-sensitive-data-in-temporary-tables.good.al | 3 --- .../security/protect-sensitive-data-in-temporary-tables.md | 4 +--- 13 files changed, 1 insertion(+), 12 deletions(-) rename {community => microsoft}/knowledge/security/classify-every-field-with-dataclassification.bad.al (100%) rename {community => microsoft}/knowledge/security/classify-every-field-with-dataclassification.good.al (100%) rename {community => microsoft}/knowledge/security/classify-every-field-with-dataclassification.md (100%) rename {community => microsoft}/knowledge/security/compose-permission-sets-with-included-sets.bad.al (100%) rename {community => microsoft}/knowledge/security/compose-permission-sets-with-included-sets.good.al (100%) rename {community => microsoft}/knowledge/security/compose-permission-sets-with-included-sets.md (95%) rename {community => microsoft}/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md (95%) rename {community => microsoft}/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.bad.al (100%) rename {community => microsoft}/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.good.al (100%) rename {community => microsoft}/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md (96%) rename {community => microsoft}/knowledge/security/protect-sensitive-data-in-temporary-tables.bad.al (100%) rename {community => microsoft}/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al (90%) rename {community => microsoft}/knowledge/security/protect-sensitive-data-in-temporary-tables.md (77%) diff --git a/community/knowledge/security/classify-every-field-with-dataclassification.bad.al b/microsoft/knowledge/security/classify-every-field-with-dataclassification.bad.al similarity index 100% rename from community/knowledge/security/classify-every-field-with-dataclassification.bad.al rename to microsoft/knowledge/security/classify-every-field-with-dataclassification.bad.al diff --git a/community/knowledge/security/classify-every-field-with-dataclassification.good.al b/microsoft/knowledge/security/classify-every-field-with-dataclassification.good.al similarity index 100% rename from community/knowledge/security/classify-every-field-with-dataclassification.good.al rename to microsoft/knowledge/security/classify-every-field-with-dataclassification.good.al diff --git a/community/knowledge/security/classify-every-field-with-dataclassification.md b/microsoft/knowledge/security/classify-every-field-with-dataclassification.md similarity index 100% rename from community/knowledge/security/classify-every-field-with-dataclassification.md rename to microsoft/knowledge/security/classify-every-field-with-dataclassification.md diff --git a/community/knowledge/security/compose-permission-sets-with-included-sets.bad.al b/microsoft/knowledge/security/compose-permission-sets-with-included-sets.bad.al similarity index 100% rename from community/knowledge/security/compose-permission-sets-with-included-sets.bad.al rename to microsoft/knowledge/security/compose-permission-sets-with-included-sets.bad.al diff --git a/community/knowledge/security/compose-permission-sets-with-included-sets.good.al b/microsoft/knowledge/security/compose-permission-sets-with-included-sets.good.al similarity index 100% rename from community/knowledge/security/compose-permission-sets-with-included-sets.good.al rename to microsoft/knowledge/security/compose-permission-sets-with-included-sets.good.al diff --git a/community/knowledge/security/compose-permission-sets-with-included-sets.md b/microsoft/knowledge/security/compose-permission-sets-with-included-sets.md similarity index 95% rename from community/knowledge/security/compose-permission-sets-with-included-sets.md rename to microsoft/knowledge/security/compose-permission-sets-with-included-sets.md index 7fb94cb..cdeec49 100644 --- a/community/knowledge/security/compose-permission-sets-with-included-sets.md +++ b/microsoft/knowledge/security/compose-permission-sets-with-included-sets.md @@ -9,8 +9,6 @@ application-area: [all] # Compose permission sets with IncludedPermissionSets -> Contributions welcome — open a PR to refine or extend this article. - ## Description The `IncludedPermissionSets` property lets one AL permission set reference another, composing rights out of smaller building blocks. Combined with `Assignable = false` on the building blocks, an extension can ship focused per-module units (a table-data cluster, an API-access cluster) and assemble role-shaped sets that include them. Adding an object updates one building block, and every role-shaped set that includes it inherits the change automatically — instead of drifting apart across duplicated definitions. diff --git a/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md b/microsoft/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md similarity index 95% rename from community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md rename to microsoft/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md index 387c778..bc9a4e8 100644 --- a/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md +++ b/microsoft/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md @@ -9,8 +9,6 @@ application-area: [all] # Do not grant rights beyond a user's entitlement -> Contributions welcome — open a PR to refine or extend this article. - ## Description Entitlements are license-level caps on what a user can access, derived automatically from the BC license tier. Permission sets are application-level grants administered on top of the entitlement. A permission set can only grant within the entitlement's boundaries; grants beyond those boundaries are silently clipped at runtime. This means a permission set authored and validated in a developer sandbox (with a broad license) can appear to work correctly there and fail silently in a customer tenant where users hold a narrower entitlement. diff --git a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.bad.al b/microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.bad.al similarity index 100% rename from community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.bad.al rename to microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.bad.al diff --git a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.good.al b/microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.good.al similarity index 100% rename from community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.good.al rename to microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.good.al diff --git a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md b/microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md similarity index 96% rename from community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md rename to microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md index f12a4f9..131bb9b 100644 --- a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md +++ b/microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md @@ -9,8 +9,6 @@ application-area: [all] # Prefer OAuth2 over API keys for external HTTP calls -> Contributions welcome — open a PR to refine or extend this article. - ## Description External HTTP integrations from AL can authenticate using OAuth 2.0 (client-credentials for service-to-service, authorization-code for user-delegated), API keys, basic authentication, or credentials in URLs. The mechanisms differ substantially in the blast radius of a leaked secret and in how cleanly tokens can be rotated. OAuth-issued tokens expire on their own schedule and rotate cleanly; API keys and basic-auth passwords typically have to be rotated manually and usually live unencrypted in a configuration table. When the partner supports OAuth, the difference is a material security improvement, not a stylistic preference. diff --git a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.bad.al b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.bad.al similarity index 100% rename from community/knowledge/security/protect-sensitive-data-in-temporary-tables.bad.al rename to microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.bad.al diff --git a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al similarity index 90% rename from community/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al rename to microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al index 54bf2bb..a0c9f77 100644 --- a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al +++ b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al @@ -19,9 +19,6 @@ codeunit 50100 "Customer Temp Processor" until Customer.Next() = 0; ProcessCustomerBuffer(TempCustomer); - - // Explicit cleanup on the normal exit path. - TempCustomer.DeleteAll(); exit(true); end; diff --git a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.md similarity index 77% rename from community/knowledge/security/protect-sensitive-data-in-temporary-tables.md rename to microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.md index 3f4db02..7eedc02 100644 --- a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md +++ b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.md @@ -9,15 +9,13 @@ application-area: [all] # Protect sensitive data in temporary tables -> Contributions welcome — open a PR to refine or extend this article. - ## Description A temporary record copies data out of the source table into session memory. The platform does not automatically enforce the source table's permission model on the copy, and a value written to a temporary buffer can outlive the procedure that put it there if the buffer is a global or is passed upward. Code that places sensitive rows into a temporary table is therefore responsible for the checks and cleanup the source table would otherwise provide. ## Best Practice -Validate the caller's read permission on the source table before populating the temporary buffer. Keep the buffer's lifetime as short as the work requires, and delete its contents on every exit path — including error paths — so sensitive values do not linger. Prefer local temporary variables over globals for anything carrying sensitive data. +Validate the caller's read permission on the source table before populating the temporary buffer. Keep the buffer's lifetime as short as the work requires, and prefer local temporary variables over globals for anything carrying sensitive data — a local buffer's contents are discarded automatically when the procedure returns. When a buffer must be global or is passed back to callers, delete its contents on every exit path — including error paths — so sensitive values do not linger. See sample: `protect-sensitive-data-in-temporary-tables.good.al`. From aca3986fd0702ed6829b71c8ef6645d87550ae70 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 14 Jul 2026 11:25:03 +0200 Subject: [PATCH 14/86] Correct security and privacy knowledge guidance (#92) * Correct security and privacy guidance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9c2eebc4-dcd5-4b85-8113-90772d818900 * Address security privacy review findings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9c2eebc4-dcd5-4b85-8113-90772d818900 --------- Co-authored-by: Jesper Schulz-Wedde --- ...id-strsubstno-prebuild-before-error.bad.al | 14 +++++++--- .../avoid-strsubstno-prebuild-before-error.md | 10 +++---- ...-direct-substitution-safe-for-telemetry.md | 10 +++---- .../error-vs-message-telemetry-logging.md | 10 +++---- ...rrortext-customer-content-in-errors.bad.al | 16 +++++++---- ...rortext-customer-content-in-errors.good.al | 6 ++--- ...asterrortext-customer-content-in-errors.md | 8 +++--- ...-consent-for-external-data-transfer.bad.al | 8 ++++-- ...consent-for-external-data-transfer.good.al | 27 ++++++++++++++----- ...tice-consent-for-external-data-transfer.md | 10 +++---- ...on-in-privacy-notice-registrations.good.al | 16 +++++++---- ...gration-in-privacy-notice-registrations.md | 10 +++---- ...level-data-classification-cascades.good.al | 17 ++++++++---- ...able-level-data-classification-cascades.md | 10 +++---- ...e-access-must-be-local-or-internal.good.al | 2 +- ...torage-access-must-be-local-or-internal.md | 2 +- ...storage-datascope-module-vs-company.bad.al | 2 +- ...torage-datascope-module-vs-company.good.al | 4 +-- ...atedstorage-datascope-module-vs-company.md | 2 +- ...-setencrypted-for-sensitive-values.good.al | 9 ++++--- ...orage-setencrypted-for-sensitive-values.md | 4 +-- ...required-when-unwrapping-secrettext.bad.al | 17 +++++------- ...equired-when-unwrapping-secrettext.good.al | 18 +++++-------- ...ble-required-when-unwrapping-secrettext.md | 10 +++---- ...retstrsubstno-for-composing-secrets.bad.al | 13 ++++++--- ...etstrsubstno-for-composing-secrets.good.al | 6 ++--- .../secretstrsubstno-for-composing-secrets.md | 8 +++--- .../secrettext-for-credentials.good.al | 5 +--- .../security/secrettext-for-credentials.md | 4 +-- .../secrettext-with-httpclient.bad.al | 8 +++--- .../secrettext-with-httpclient.good.al | 20 +++++++++----- .../security/secrettext-with-httpclient.md | 10 +++---- ...etablerelation-false-on-user-input.good.al | 21 +++++++-------- ...lidatetablerelation-false-on-user-input.md | 8 +++--- 34 files changed, 192 insertions(+), 153 deletions(-) diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al index 08f1702..fd1ded5 100644 --- a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al +++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al @@ -2,10 +2,16 @@ codeunit 50207 "Privacy Sample StrSubstNo Bad" { procedure ReportFailure(var Customer: Record Customer) var - ErrorMsg: Text; + CustomerInvalidErr: Label 'Customer %1 has invalid data.', Comment = '%1 = Customer No.'; begin - ErrorMsg := StrSubstNo('Customer %1 (%2) at %3 has invalid data', - Customer.Name, Customer."E-Mail", Customer.Address); - Error(ErrorMsg); + Error(StrSubstNo(CustomerInvalidErr, Customer."No.")); + end; + + procedure ReportCombinedFailure() + var + HeaderErr: Label 'Customer validation failed. '; + DetailErr: Label 'Correct the customer card and try again.'; + begin + Error(HeaderErr + DetailErr); end; } diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md index 7d4c1e7..5d292fa 100644 --- a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md +++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [20..] domain: privacy keywords: [strsubstno, error, telemetry, pii, prebuild, text-variable] technologies: [al] @@ -7,20 +7,20 @@ countries: [w1] application-area: [all] --- -# Do not pre-build an error string with `StrSubstNo` before calling `Error()` +# Pass a Label directly as the first Error argument ## 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. +Error method trace telemetry includes the AL error string only when the first `Error` argument is a `Label` or `TextConst`. Wrapping a label in `StrSubstNo`, or concatenating labels or text, produces a dynamic `Text` first argument. In that case the actual string is not emitted as the telemetry message; the platform emits its generic guidance instead. CodeCop AA0231 flags both shapes because the label identity and data-classification context are lost. ## 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`. +Declare the complete message as a `Label` or `TextConst` and pass it directly to `Error`, followed by substitution values. The client receives the formatted message while telemetry can retain the static message template without using the dynamic values as its message. See `error-direct-substitution-safe-for-telemetry.md`. 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()`. +`Error(StrSubstNo(CustomerInvalidErr, Customer."No."))` and `Error(HeaderErr + DetailErr)` both make the first argument dynamic. They reduce error telemetry quality; they do not cause that composed string to be logged verbatim as the telemetry message. See sample: `avoid-strsubstno-prebuild-before-error.bad.al`. diff --git a/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md index 8653ecf..f4cac77 100644 --- a/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md +++ b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [20..] domain: privacy keywords: [error, strsubstno, direct-substitution, telemetry, classification, label] technologies: [al] @@ -7,18 +7,18 @@ countries: [w1] application-area: [all] --- -# `Error()` with direct substitution parameters is always safe for telemetry +# Use a Label or TextConst for the Error telemetry message ## 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. +For Error method trace telemetry, the platform includes the AL error string only when `Error` receives a `Label` or `TextConst` as its first argument. Substitution values format the client message, but the static label supplies the telemetry message and preserves its classification context. A string literal, local `Text`, `StrSubstNo` result, or concatenation is not equivalent: telemetry substitutes generic guidance instead of that dynamic string. ## 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. +Define the complete error template as a `Label` with placeholder comments, pass the label directly as the first argument, and pass values separately. Independently review whether those values are appropriate to show to the current user. 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`. +Assuming that any direct format string is telemetry-safe, or that a `StrSubstNo`/concatenated first argument is logged verbatim. The required telemetry shape is specifically a directly supplied `Label` or `TextConst`; see `avoid-strsubstno-prebuild-before-error.md`. diff --git a/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md b/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md index f7372b7..2654dff 100644 --- a/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md +++ b/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [20..] domain: privacy keywords: [error, message, confirm, notification, telemetry, logging, ui-dialog] technologies: [al] @@ -7,16 +7,16 @@ countries: [w1] application-area: [all] --- -# Only `Error()` is logged to telemetry — `Message`, `Confirm`, `Notification` are not +# Error dialogs emit Error method trace telemetry ## 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. +When `Error` displays a dialog, Business Central emits the RT0030 Error method trace telemetry signal. `Message`, `Confirm`, and `Notification` do not emit that Error method trace signal. For RT0030, the actual AL error string is included only when the first `Error` argument is a `Label` or `TextConst`; other first-argument types produce generic guidance instead of the dynamic string. ## 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. +Use a `Label` or `TextConst` as the direct first argument to `Error` so telemetry contains a stable, classified message. Review user-facing substitution values for UI appropriateness. Do not treat `Message`, `Confirm`, or `Notification` content as though it were automatically copied into RT0030. ## 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. +Claiming that every rendered `Error` string is written verbatim to telemetry, or that `Message`, `Confirm`, and `Notification` automatically feed the Error method trace. Both overstate the platform behavior. diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al index b943031..432aee9 100644 --- a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al +++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al @@ -2,12 +2,18 @@ codeunit 50209 "Privacy Sample GetLastError Bad" { procedure AddAttachment() var - ErrorMsg: Text; + AttachmentFailedErr: Label 'Attachment failed: %1', Comment = '%1 = underlying error'; begin - if not TryAddAttachment() then begin - ErrorMsg := StrSubstNo('Attachment failed: %1', GetLastErrorText(true)); - Error(ErrorMsg); - end; + if not TryAddAttachment() then + Error(StrSubstNo(AttachmentFailedErr, GetLastErrorText())); + end; + + procedure AddAttachmentWithConcatenation() + var + AttachmentFailedErr: Label 'Attachment failed: '; + begin + if not TryAddAttachment() then + Error(AttachmentFailedErr + GetLastErrorText()); end; [TryFunction] diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al index 4b07537..ff63592 100644 --- a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al +++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al @@ -2,15 +2,15 @@ codeunit 50208 "Privacy Sample GetLastError Good" { procedure AddAttachmentSafely() var - AttachmentFailedErr: Label 'Failed to add email attachment. Please try again.'; + AttachmentFailedErr: Label 'Failed to add the attachment: %1', Comment = '%1 = underlying error shown to the user'; begin if not TryAddAttachment() then - Error(AttachmentFailedErr); + Error(AttachmentFailedErr, GetLastErrorText()); end; [TryFunction] local procedure TryAddAttachment() begin - // ... attachment logic that may fail with a customer-data-bearing error ... + // Attachment logic that can 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 index 769a3f5..8ff26a8 100644 --- a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md +++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [20..] domain: privacy keywords: [getlasterrortext, error, strsubstno, telemetry, customer-data, attachment] technologies: [al] @@ -11,16 +11,16 @@ application-area: [all] ## 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`). +Parameterless `GetLastErrorText()` can contain customer content such as field values, record keys, and file names. The Boolean overload names its parameter `ExcludeCustomerContent`; passing `true` requests scrubbed text and is not the customer-content scenario covered here. When unsanitized error text is passed as a substitution value to an `Error` whose first argument is a `Label` or `TextConst`, the label supplies the Error method trace telemetry message. ## 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. +Use a generic label when the user does not need the underlying detail. If showing unsanitized detail is appropriate, put `%1` in a label and pass parameterless `GetLastErrorText()` as a separate argument. This preserves a useful static telemetry message while keeping the dynamic value out of the telemetry message field. 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. +`Error(StrSubstNo(AttachmentFailedErr, GetLastErrorText()))` or `Error(AttachmentPrefixErr + GetLastErrorText())`. Both lose the static first argument and trigger AA0231; neither causes the composed text to be logged verbatim as the Error telemetry message. See sample: `getlasterrortext-customer-content-in-errors.bad.al`. 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 index 6308674..d0d1c4a 100644 --- 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 @@ -4,10 +4,14 @@ codeunit 50217 "Privacy Sample Consent Bad" var HttpClient: HttpClient; Content: HttpContent; + Payload: JsonObject; + PayloadText: Text; Response: HttpResponseMessage; begin - Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}', - Customer."E-Mail", Customer.Name)); + Payload.Add('email', Customer."E-Mail"); + Payload.Add('name', Customer.Name); + Payload.WriteTo(PayloadText); + Content.WriteFrom(PayloadText); 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 index 0ac939c..3c59759 100644 --- 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 @@ -1,22 +1,35 @@ codeunit 50216 "Privacy Sample Consent Good" { + var + ExternalSyncNoticeIdLbl: Label 'CONTOSO-EXTERNAL-SYNC', Locked = true; + ExternalSyncNameLbl: Label 'Contoso External Sync', Locked = true; + PrivacyTermsUrlLbl: Label 'https://contoso.example/privacy', Locked = true; + + internal procedure RegisterPrivacyNotice() + var + PrivacyNotice: Codeunit "Privacy Notice"; + begin + PrivacyNotice.CreatePrivacyNotice( + ExternalSyncNoticeIdLbl, ExternalSyncNameLbl, PrivacyTermsUrlLbl); + end; + procedure SendDataToExternalService(Customer: Record Customer) var PrivacyNotice: Codeunit "Privacy Notice"; - PrivacyNoticeRegistrations: Codeunit "Privacy Notice Registrations"; HttpClient: HttpClient; Content: HttpContent; + Payload: JsonObject; + PayloadText: Text; Response: HttpResponseMessage; PrivacyConsentRequiredErr: Label 'Privacy notice consent is required for this integration.'; begin - if PrivacyNotice.GetPrivacyNoticeApprovalState( - PrivacyNoticeRegistrations.GetExchangePrivacyNoticeId()) - <> "Privacy Notice Approval State"::Agreed - then + if not PrivacyNotice.ConfirmPrivacyNoticeApproval(ExternalSyncNoticeIdLbl) then Error(PrivacyConsentRequiredErr); - Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}', - Customer."E-Mail", Customer.Name)); + Payload.Add('email', Customer."E-Mail"); + Payload.Add('name', Customer.Name); + Payload.WriteTo(PayloadText); + Content.WriteFrom(PayloadText); 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 index a064792..d95acef 100644 --- a/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md +++ b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md @@ -1,26 +1,26 @@ --- bc-version: [all] domain: privacy -keywords: [privacy-notice, consent, http-client, outgoing-request, external-service, getprivacynoticeapprovalstate] +keywords: [privacy-notice, consent, http-client, outgoing-request, external-service, confirmprivacynoticeapproval] technologies: [al] countries: [w1] application-area: [all] --- -# Outgoing requests to external services require a Privacy Notice consent check +# Check the custom Privacy Notice before external data transfer ## 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. +Business Central's `Codeunit "Privacy Notice"` creates notices and records per-integration approval. A custom integration needs its own stable notice ID; it must not borrow the Exchange or another built-in service's consent. `ConfirmPrivacyNoticeApproval` shows the notice when needed and returns whether the request is approved. `GetPrivacyNoticeApprovalState` checks an existing notice without showing UI. ## 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. +Register the custom notice with `CreatePrivacyNotice` during setup or through `OnRegisterPrivacyNotices`. Before sending data, call `ConfirmPrivacyNoticeApproval()` outside a write transaction, or check `GetPrivacyNoticeApprovalState()` when the flow must not show UI. No path should issue the request without approval. 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. +A custom integration that posts data without checking its own notice, or that gates the call with a built-in ID such as the Exchange privacy notice ID. Consent for one service does not authorize another. 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 index d8a20f5..4a93cbe 100644 --- a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al +++ b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al @@ -1,11 +1,17 @@ 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"; + ExternalSyncNoticeIdLbl: Label 'CONTOSO-EXTERNAL-SYNC', Locked = true; + ExternalSyncNameLbl: Label 'Contoso External Sync', Locked = true; + PrivacyTermsUrlLbl: Label 'https://contoso.example/privacy', Locked = true; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Privacy Notice", 'OnRegisterPrivacyNotices', '', false, false)] + local procedure OnRegisterPrivacyNotices(var TempPrivacyNotice: Record "Privacy Notice" temporary) begin - PrivacyNotice.CreatePrivacyNoticeForIntegration( - 'My External Sync', 'External Customer Sync Service'); + TempPrivacyNotice.Init(); + TempPrivacyNotice.ID := ExternalSyncNoticeIdLbl; + TempPrivacyNotice."Integration Service Name" := ExternalSyncNameLbl; + TempPrivacyNotice.Link := PrivacyTermsUrlLbl; + if not TempPrivacyNotice.Insert() then; 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 index 40779e9..4e76779 100644 --- a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md +++ b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md @@ -1,24 +1,24 @@ --- bc-version: [all] domain: privacy -keywords: [privacy-notice-registrations, integration, register, exchange, onedrive, teams, notice-id] +keywords: [privacy-notice, integration, register, onregisterprivacynotices, notice-id] technologies: [al] countries: [w1] application-area: [all] --- -# Register every new external integration with `Privacy Notice Registrations` +# Register custom integrations with Codeunit Privacy Notice ## 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. +The current extension point is `Codeunit "Privacy Notice"`. Extensions can subscribe to its `OnRegisterPrivacyNotices` event and add a dedicated notice ID, integration name, and link to the temporary `Privacy Notice` record. For explicit creation outside the default-registration flow, the same codeunit exposes `CreatePrivacyNotice`. `Codeunit "Privacy Notice Registrations"` contains IDs for built-in integrations and is not the registration API for a custom service. ## 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`. +Choose a stable ID owned by the extension. Register it through `OnRegisterPrivacyNotices`, or call `PrivacyNotice.CreatePrivacyNotice` during an intentional setup or upgrade path. Use that same ID for consent checks 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. +Reusing the Exchange or another built-in notice ID for a custom integration, subscribing to `Privacy Notice Registrations`, or calling the nonexistent `CreatePrivacyNoticeForIntegration` method. These shapes attach consent to the wrong service or do not compile. diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al index e1e5808..80a4345 100644 --- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al @@ -1,12 +1,19 @@ 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]) { } + field(1; "Entry No."; Integer) + { + DataClassification = SystemMetadata; + } + field(2; "Changed By"; Code[50]) + { + DataClassification = EndUserIdentifiableInformation; + } + field(3; "Change Description"; Text[250]) + { + DataClassification = CustomerContent; + } } keys diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md index bf457e9..cd31d07 100644 --- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md @@ -1,24 +1,24 @@ --- bc-version: [all] domain: privacy -keywords: [data-classification, table-level, inheritance, override, cascading] +keywords: [data-classification, table-level, normal-field, appsourcecop, as0016] technologies: [al] countries: [w1] application-area: [all] --- -# Table-level DataClassification cascades to every field unless overridden +# Set DataClassification on every Normal table field ## 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. +AppSourceCop AS0016 requires every field whose `FieldClass` is `Normal` to declare `DataClassification` and use a value other than `ToBeClassified`. A table-level `DataClassification` property does not satisfy that field-level requirement. FlowFields and FlowFilters are handled separately by the platform and are covered by `flowfield-flowfilter-classification-systemmetadata.md`. ## 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. +Classify each Normal field according to the data it stores, even when every field in the table has the same classification. Repeat the property explicitly so AS0016 can verify every field. 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. +Relying on `DataClassification` at table scope and leaving Normal fields unclassified. The table property does not cascade in the way AS0016 requires, so the fields still fail AppSourceCop validation. 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 index 22e279b..8dd0069 100644 --- 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 @@ -8,7 +8,7 @@ codeunit 50215 "Sec Sample IsoStorage Good" exit(true); end; - internal procedure SetApiKey(NewKey: Text) + internal procedure SetApiKey(NewKey: SecretText) 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 index cbf5d5d..d887d92 100644 --- a/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md +++ b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [24..] domain: security keywords: [isolatedstorage, local, internal, public, getter, setter, encapsulation] technologies: [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 index 60efe31..63a8649 100644 --- a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al +++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al @@ -1,6 +1,6 @@ codeunit 50220 "Sec Sample DataScope Bad" { - internal procedure StoreCompanyWebhook(WebhookUrl: Text) + internal procedure StoreCompanyWebhook(WebhookUrl: SecretText) begin IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Module); 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 index 397635f..f91bb26 100644 --- a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al +++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al @@ -1,11 +1,11 @@ codeunit 50219 "Sec Sample DataScope Good" { - internal procedure StoreTenantApiKey(ApiKey: Text) + internal procedure StoreTenantApiKey(ApiKey: SecretText) begin IsolatedStorage.SetEncrypted('TenantApiKey', ApiKey, DataScope::Module); end; - internal procedure StoreCompanyWebhook(WebhookUrl: Text) + internal procedure StoreCompanyWebhook(WebhookUrl: SecretText) begin IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Company); end; diff --git a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md index 711895d..111daf0 100644 --- a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md +++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [24..] domain: security keywords: [isolatedstorage, datascope, module, company, user, scope] technologies: [al] diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al index 215055c..ec1adcc 100644 --- a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al +++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al @@ -1,10 +1,11 @@ codeunit 50217 "Sec Sample SetEncrypted Good" { - internal procedure StoreApiKey(ApiKeyValue: Text) + internal procedure StoreApiKey(ApiKeyValue: SecretText) + var + StoreApiKeyFailedErr: Label 'The API key could not be stored.'; begin - if StrLen(ApiKeyValue) > 200 then - Error('API key too long for encrypted storage'); - IsolatedStorage.SetEncrypted('ApiKey', ApiKeyValue, DataScope::Module); + if not IsolatedStorage.SetEncrypted('ApiKey', ApiKeyValue, DataScope::Module) then + Error(StoreApiKeyFailedErr); end; local procedure ReadApiKey(var ApiKey: SecretText): Boolean diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md index 4e4f6b9..bf2019d 100644 --- a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md +++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [24..] domain: security keywords: [isolatedstorage, setencrypted, encryption, secret, storage] technologies: [al] @@ -15,7 +15,7 @@ application-area: [all] ## 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`. +Use the `SecretText` overloads of `IsolatedStorage.SetEncrypted` and `IsolatedStorage.Get` for values that meet the definition of a secret. Check the optional Boolean result when storage failure needs a controlled error; encrypted values are subject to the documented storage-size limit. See sample: `isolatedstorage-setencrypted-for-sensitive-values.good.al`. ## Anti Pattern diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al index a22b60f..ece0fd8 100644 --- a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al +++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al @@ -1,19 +1,14 @@ codeunit 50214 "Sec Sample NonDebug Bad" { - procedure BuildConnectionString(ApiKey: SecretText): Text + procedure CallLegacyOnPremisesConsumer(ApiKey: SecretText) + var + PlainApiKey: Text; begin - exit('Server=db.example.com;Key=' + ApiKey.Unwrap()); + PlainApiKey := ApiKey.Unwrap(); + InvokeLegacyConsumer(PlainApiKey); end; - procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) - var - ResponseText: Text; - JsonObject: JsonObject; - JsonToken: JsonToken; + local procedure InvokeLegacyConsumer(ApiKey: Text) 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 index 421e9bd..6b4c045 100644 --- a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al +++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al @@ -1,21 +1,17 @@ codeunit 50213 "Sec Sample NonDebug Good" { [NonDebuggable] - procedure BuildConnectionString(ApiKey: SecretText): Text + procedure CallLegacyOnPremisesConsumer(ApiKey: SecretText) + var + PlainApiKey: Text; begin - exit('Server=db.example.com;Key=' + ApiKey.Unwrap()); + PlainApiKey := ApiKey.Unwrap(); + InvokeLegacyConsumer(PlainApiKey); end; [NonDebuggable] - procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) - var - ResponseText: Text; - JsonObject: JsonObject; - JsonToken: JsonToken; + local procedure InvokeLegacyConsumer(ApiKey: Text) begin - Response.Content.ReadAs(ResponseText); - JsonObject.ReadFrom(ResponseText); - JsonObject.Get('access_token', JsonToken); - SessionToken := JsonToken.AsValue().AsText(); + // The on-premises legacy consumer accepts only Text. end; } diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md index b214977..2c5dce8 100644 --- a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md +++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [23..] domain: security keywords: [nondebuggable, attribute, secrettext, unwrap, debugger] technologies: [al] @@ -7,16 +7,16 @@ countries: [w1] application-area: [all] --- -# Mark procedures that call SecretText.Unwrap() as [NonDebuggable] +# On-premises only: protect unavoidable SecretText.Unwrap calls ## 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). +`SecretText.Unwrap()` is supported only for Business Central on-premises and exists for compatibility. It converts a protected value to plain `Text`, where debugger redaction no longer applies. `[NonDebuggable]` prevents the debugger from inspecting a procedure's parameters and locals, but it does not make the resulting `Text` safe to return, log, or pass through debuggable code. ## 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`. +In SaaS, keep the value as `SecretText` and use secret-aware APIs instead of unwrapping. For an unavoidable on-premises legacy API that accepts only `Text`, keep the plain-text path as short as possible and mark every procedure in that path `[NonDebuggable]`. Do not return the unwrapped value. 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`. +Calling `Unwrap()` in cloud-targeted code, or calling it in an on-premises procedure that is debuggable or returns the resulting `Text`. Both defeat the protection that `SecretText` provides. See sample: `nondebuggable-required-when-unwrapping-secrettext.bad.al`. diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al index 84dda45..7ff4232 100644 --- a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al +++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al @@ -1,12 +1,17 @@ codeunit 50212 "Sec Sample SecretSubst Bad" { - procedure BuildAuthHeader(Token: SecretText): Text + procedure BuildAuthHeader(Token: Text): Text begin - exit(StrSubstNo('Bearer %1', Token.Unwrap())); + exit(StrSubstNo('Token %1', Token)); end; - procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): Text + procedure BuildSecretUri(ApiKey: Text): Text begin - exit(BaseUrl + '?key=' + ApiKey.Unwrap()); + exit(StrSubstNo('https://api.example.com/data?key=%1', ApiKey)); + end; + + procedure BuildBrokenAuthHeader(Token: SecretText): SecretText + begin + exit(SecretStrSubstNo('Token', Token)); end; } diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al index f550025..0ef1282 100644 --- a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al +++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al @@ -2,11 +2,11 @@ codeunit 50211 "Sec Sample SecretSubst Good" { procedure BuildAuthHeader(Token: SecretText): SecretText begin - exit(SecretStrSubstNo('Bearer %1', Token)); + exit(SecretStrSubstNo('Token %1', Token)); end; - procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): SecretText + procedure BuildSecretUri(ApiKey: SecretText): SecretText begin - exit(SecretStrSubstNo('%1?key=%2', BaseUrl, ApiKey)); + exit(SecretStrSubstNo('https://api.example.com/data?key=%1', ApiKey)); end; } diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md index 6e315f7..4c0fa41 100644 --- a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md +++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [23..] domain: security keywords: [secretstrsubstno, secrettext, strsubstno, format, compose] technologies: [al] @@ -11,12 +11,12 @@ application-area: [all] ## 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. +`SecretStrSubstNo` is the secret-preserving counterpart of `StrSubstNo`. It inserts `SecretText` arguments into `%1`, `%2`, and similar placeholders and returns `SecretText` without materializing the result as plain text. It is the right tool for values such as a `Token %1` authorization header or a URI with an API key placeholder. ## 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`. +Compose every secret-bearing string through `SecretStrSubstNo`, ensure the format contains a placeholder for each secret, and keep the result as `SecretText`. Pass it to `HttpRequestMessage.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`. +Keeping a credential in `Text` and inserting it with `StrSubstNo`, or calling `SecretStrSubstNo` with a format that has no placeholder for the secret. The first exposes the value as plain text; the second silently omits it. See sample: `secretstrsubstno-for-composing-secrets.bad.al`. diff --git a/microsoft/knowledge/security/secrettext-for-credentials.good.al b/microsoft/knowledge/security/secrettext-for-credentials.good.al index d98f127..2eb0355 100644 --- a/microsoft/knowledge/security/secrettext-for-credentials.good.al +++ b/microsoft/knowledge/security/secrettext-for-credentials.good.al @@ -1,14 +1,11 @@ codeunit 50207 "Sec Sample SecretText Good" { - procedure CallExternalApi() + procedure CallExternalApi(ApiKey: SecretText) 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); diff --git a/microsoft/knowledge/security/secrettext-for-credentials.md b/microsoft/knowledge/security/secrettext-for-credentials.md index 17fec22..26a2511 100644 --- a/microsoft/knowledge/security/secrettext-for-credentials.md +++ b/microsoft/knowledge/security/secrettext-for-credentials.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [23..] domain: security keywords: [secrettext, credentials, api-key, token, debugger, unwrap] technologies: [al] @@ -15,7 +15,7 @@ application-area: [all] ## 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`. +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 HTTP header or URI). Never round-trip through `Text`. On BC 24 and later, use the `SecretText` overload of `IsolatedStorage.Get` when retrieving stored secrets. See sample: `secrettext-for-credentials.good.al`. ## Anti Pattern diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.bad.al b/microsoft/knowledge/security/secrettext-with-httpclient.bad.al index 6ddb883..6b11a4c 100644 --- a/microsoft/knowledge/security/secrettext-with-httpclient.bad.al +++ b/microsoft/knowledge/security/secrettext-with-httpclient.bad.al @@ -1,23 +1,23 @@ codeunit 50210 "Sec Sample SecretHttp Bad" { - procedure CallApiWithSecretInUri(ApiKey: SecretText) + procedure CallApiWithSecretInUri(ApiKey: Text) var HttpClient: HttpClient; Response: HttpResponseMessage; RequestUri: Text; begin - RequestUri := 'https://api.example.com/data?key=' + ApiKey.Unwrap(); + RequestUri := StrSubstNo('https://api.example.com/data?key=%1', ApiKey); HttpClient.Get(RequestUri, Response); end; - procedure CallApiWithBearer(BearerToken: SecretText) + procedure CallApiWithAccessToken(AccessToken: Text) var HttpClient: HttpClient; Response: HttpResponseMessage; Headers: HttpHeaders; begin Headers := HttpClient.DefaultRequestHeaders(); - Headers.Add('Authorization', 'Bearer ' + BearerToken.Unwrap()); + Headers.Add('Authorization', StrSubstNo('Token %1', AccessToken)); 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 index 50f0e31..e552512 100644 --- a/microsoft/knowledge/security/secrettext-with-httpclient.good.al +++ b/microsoft/knowledge/security/secrettext-with-httpclient.good.al @@ -3,26 +3,32 @@ codeunit 50209 "Sec Sample SecretHttp Good" procedure CallApiWithSecretUri(ApiKey: SecretText) var HttpClient: HttpClient; + Request: HttpRequestMessage; Response: HttpResponseMessage; SecretUri: SecretText; begin SecretUri := SecretStrSubstNo('https://api.example.com/data?key=%1', ApiKey); - HttpClient.SetSecretRequestUri(SecretUri); - HttpClient.Get('', Response); + Request.Method := 'GET'; + Request.SetSecretRequestUri(SecretUri); + HttpClient.Send(Request, Response); end; - procedure CallApiWithBearer(BearerToken: SecretText) + procedure CallApiWithAccessToken(AccessToken: SecretText) var HttpClient: HttpClient; + Request: HttpRequestMessage; Response: HttpResponseMessage; Headers: HttpHeaders; AuthHeader: SecretText; + AuthorizationHeaderMissingErr: Label 'Authorization header missing.'; begin - AuthHeader := SecretStrSubstNo('Bearer %1', BearerToken); - Headers := HttpClient.DefaultRequestHeaders(); + Request.Method := 'GET'; + Request.SetRequestUri('https://api.example.com/data'); + Request.GetHeaders(Headers); + AuthHeader := SecretStrSubstNo('Token %1', AccessToken); Headers.Add('Authorization', AuthHeader); if not Headers.ContainsSecret('Authorization') then - Error('Authorization header missing'); - HttpClient.Get('https://api.example.com/data', Response); + Error(AuthorizationHeaderMissingErr); + HttpClient.Send(Request, Response); end; } diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.md b/microsoft/knowledge/security/secrettext-with-httpclient.md index f8be895..dc67e3e 100644 --- a/microsoft/knowledge/security/secrettext-with-httpclient.md +++ b/microsoft/knowledge/security/secrettext-with-httpclient.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [23..] domain: security keywords: [secrettext, httpclient, setsecretrequesturi, containssecret, headers, http] technologies: [al] @@ -7,16 +7,16 @@ countries: [w1] application-area: [all] --- -# Use the SecretText-aware HttpClient surface for secrets in requests +# Set secret request URIs on HttpRequestMessage ## 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. +The secret URI API belongs to `HttpRequestMessage`, not `HttpClient`. `HttpRequestMessage.SetSecretRequestUri(SecretText)` keeps a credential-bearing URI protected, and the prepared request is sent with `HttpClient.Send`. Companion APIs also accept `SecretText`, including `HttpHeaders.Add` for authorization headers and `HttpContent.WriteFrom` for secret request bodies. ## 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`. +Compose a secret URI with `SecretStrSubstNo`, call `Request.SetSecretRequestUri(SecretUri)`, set the request method, and send the request with `HttpClient.Send(Request, Response)`. For authorization, get the request headers, add a `SecretText` value, and use `ContainsSecret` when checking for that 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`. +Holding a credential in `Text`, interpolating it with `StrSubstNo` or concatenation, and passing that plain text to `HttpClient.Get` or `HttpHeaders.Add`. The secret-aware request and header APIs remove the need to materialize the value as `Text`. See sample: `secrettext-with-httpclient.bad.al`. diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al index 9a49bc3..ae414db 100644 --- a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al +++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al @@ -2,24 +2,21 @@ 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]) + field(50223; "External Customer Ref"; Code[50]) { TableRelation = Customer."No."; ValidateTableRelation = false; + TestTableRelation = false; + trigger OnValidate() var - Customer: Record Customer; + InvalidExternalReferenceErr: Label 'The external customer reference must not contain spaces.'; 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"); + "External Customer Ref" := CopyStr( + UpperCase(DelChr("External Customer Ref", '<>', ' ')), + 1, MaxStrLen("External Customer Ref")); + if StrPos("External Customer Ref", ' ') > 0 then + Error(InvalidExternalReferenceErr); end; } } diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md index 275587c..d143ea5 100644 --- a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md +++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md @@ -7,16 +7,16 @@ countries: [w1] application-area: [all] --- -# Do not set ValidateTableRelation = false on user-editable fields +# Handle free-form input when ValidateTableRelation is false ## 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. +`ValidateTableRelation = false` intentionally lets a user keep free-form input even when it does not match `TableRelation`. This is supported for scenarios such as accepting a new vendor name and handling it in `OnValidate`. The risk is not the property itself; it is leaving downstream code to assume that every value identifies an existing related record. ## 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`. +Keep the default validation when values must exist in the related table. When free-form values are intentional, set both `ValidateTableRelation = false` and `TestTableRelation = false`, then add compensating `OnValidate` logic that normalizes, validates, creates, or otherwise handles unmatched input. Document that downstream code must not assume the relation exists. 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`. +`ValidateTableRelation = false` on a user-facing field with no intentional handling for unmatched values, or leaving `TestTableRelation = true` so database relation tests reject values the UI deliberately accepts. See sample: `validatetablerelation-false-on-user-input.bad.al`. From 5706959e4a2f4b6f601baf65e3a0a89e2c0ed1df Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 14 Jul 2026 11:26:16 +0200 Subject: [PATCH 15/86] Fix lifecycle compatibility guidance (#93) * Fix lifecycle compatibility guidance Correct high-confidence Business Central guidance and samples for upgrade tags, collectible errors, trigger semantics, obsoletion, events, interfaces, API contracts, and test transactions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e05a43e7-6448-4d67-9c73-798523f5d945 * Address guidance review findings Gate SecretText guidance to BC23 and clarify that the collectible-error sample intentionally emits a message-only blocking aggregate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e05a43e7-6448-4d67-9c73-798523f5d945 --------- Co-authored-by: Jesper Schulz-Wedde --- ...embers-with-the-obsolete-lifecycle.good.al | 4 +- ...lic-members-with-the-obsolete-lifecycle.md | 6 +-- ...-sensitive-data-through-public-api.good.al | 6 +-- ...xpose-sensitive-data-through-public-api.md | 2 +- ...ble-fields-instead-of-deleting-them.bad.al | 6 +-- ...e-table-fields-instead-of-deleting-them.md | 8 ++-- ...lidation-errors-with-errorbehavior.good.al | 13 +++-- ...ct-validation-errors-with-errorbehavior.md | 8 ++-- ...-add-ishandled-to-an-existing-event.bad.al | 6 +-- ...using-or-extending-existing-events.good.al | 4 +- ...nbefore-onafter-integration-events.good.al | 11 +++-- ...to-make-base-behaviour-overridable.good.al | 1 + .../set-defaultimplementation-on-enum.md | 2 +- ...attribute-governs-test-transactions.bad.al | 17 +++++++ ...ttribute-governs-test-transactions.good.al | 16 +++++++ ...del-attribute-governs-test-transactions.md | 2 +- ...sfer-skips-triggers-and-subscribers.bad.al | 4 +- ...fer-skips-triggers-and-subscribers.good.al | 19 ++++---- ...transfer-skips-triggers-and-subscribers.md | 6 +-- .../obsoletion-requires-reason-and-tag.bad.al | 4 +- ...obsoletion-requires-reason-and-tag.good.al | 2 +- .../obsoletion-requires-reason-and-tag.md | 15 +++--- ...ister-upgrade-tags-with-subscribers.bad.al | 3 +- ...ster-upgrade-tags-with-subscribers.good.al | 47 +++++++++++++------ .../register-upgrade-tags-with-subscribers.md | 10 ++-- .../upgrade/upgrade-codeunit-subtype.bad.al | 4 +- .../upgrade/upgrade-codeunit-subtype.md | 2 +- .../set-required-api-page-properties.bad.al | 9 ++-- .../set-required-api-page-properties.md | 8 ++-- ...ng-not-mutating-published-versions.good.al | 46 ++++++++++++++++-- ...-adding-not-mutating-published-versions.md | 8 ++-- .../skills/review/al-web-services-review.md | 6 +-- 32 files changed, 201 insertions(+), 104 deletions(-) diff --git a/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.good.al b/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.good.al index 4f2638a..6d6e960 100644 --- a/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.good.al +++ b/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.good.al @@ -1,7 +1,7 @@ codeunit 50305 "Net Amount Api Good" { - // Old name kept and marked obsolete: callers still compile but get a warning - // pointing at the replacement, with a tag recording the removal target version. + // Old name kept during the warning window. The tag records when obsoletion + // began; a later release deletes the method after consumers have migrated. [Obsolete('Use CalculateNetAmount instead.', '25.0')] procedure CalcNet(GrossAmount: Decimal; TaxRate: Decimal): Decimal begin diff --git a/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md b/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md index 5a699b6..35a342e 100644 --- a/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md +++ b/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md @@ -11,16 +11,16 @@ application-area: [all] ## Description -Deleting or renaming a published procedure (or object) in a single release is a hard break: dependent extensions that reference it stop compiling the moment they pick up the new version, with no warning window to migrate. AL provides a staged deprecation lifecycle precisely so consumers get advance notice. For a procedure, apply the `[Obsolete('reason', 'tag')]` attribute: the member keeps working but every caller gets a compiler warning naming the replacement and the target version. The member stays through a deprecation window — at least one major release — before it is finally removed. Object- and field-level members use the matching `ObsoleteState = Pending` → `Removed` property progression. LLMs trained to "clean up" code often delete or rename the old member immediately, skipping the window entirely. +Deleting or renaming a published procedure (or object) in a single release is a hard break: dependent extensions that reference it stop compiling the moment they pick up the new version, with no warning window to migrate. AL provides staged deprecation so consumers get advance notice. A procedure uses `[Obsolete('reason', 'tag')]`: it remains callable but callers receive a compiler warning naming the replacement and the version in which obsoletion began. Methods do not have `ObsoleteState`; after the deprecation window, the method is deleted, commonly through versioned preprocessor cleanup. Objects and fields instead use the `ObsoleteState = Pending` to `Removed` property progression. ## Best Practice -When a published procedure is superseded, keep it in place and mark it `[Obsolete('Use CalculateNetAmount instead.', '25.0')]`, where the message names the replacement and the tag records the target version for removal. Have the obsolete member forward to the new one so behavior is preserved during the window. Only after the deprecation window has elapsed — a later release — change its state to removed. This gives every dependent app a compile-time signal and time to migrate before anything actually disappears. +When a published procedure is superseded, keep it in place and mark it `[Obsolete('Use CalculateNetAmount instead.', '25.0')]`, where the message names the replacement and the tag records when the method became obsolete. Have the obsolete member forward to the new one so behavior is preserved during the window. Only after the deprecation window has elapsed should a later release delete the method. For an object or field, use `Pending` during the warning window and `Removed` afterward. See sample: `deprecate-public-members-with-the-obsolete-lifecycle.good.al`. ## Anti Pattern -Renaming or deleting the published `CalcNet` procedure in place — replacing it with `CalculateNetAmount` and nothing else — so consumers calling `CalcNet` break immediately with no deprecation notice. Detection: a previously shipped non-`local` procedure that vanished or was renamed between versions with no `[Obsolete]` marker left behind on a kept member. Mark it obsolete and keep it for a window instead. +Renaming or deleting the published `CalcNet` procedure in place — replacing it with `CalculateNetAmount` and nothing else — so consumers calling `CalcNet` break immediately with no deprecation notice. Detection: a previously shipped non-`local` procedure that vanished or was renamed between versions with no `[Obsolete]` marker left behind during a prior warning window. Do not suggest `ObsoleteState = Removed` for a method; that property belongs to supported object and element types. See sample: `deprecate-public-members-with-the-obsolete-lifecycle.bad.al`. diff --git a/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.good.al b/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.good.al index 4073930..271c7f4 100644 --- a/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.good.al +++ b/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.good.al @@ -1,10 +1,10 @@ codeunit 50320 "Payment Client Good" { var - AccessToken: Text; + AccessToken: SecretText; - // Credential flows inward through an internal setter and never leaves the object. - internal procedure SetAccessToken(NewToken: Text) + // Credential remains SecretText as it flows inward and is stored. + internal procedure SetAccessToken(NewToken: SecretText) begin AccessToken := NewToken; end; diff --git a/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md b/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md index 65c7971..bd32d2d 100644 --- a/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md +++ b/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [23..] domain: breaking-changes keywords: [sensitive-data, secrettext, token, credential, public-api, access-boundary] technologies: [al] diff --git a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.bad.al b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.bad.al index 0e2f000..1277fc2 100644 --- a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.bad.al +++ b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.bad.al @@ -3,9 +3,9 @@ table 50311 "Customer Profile Bad" fields { field(1; "No."; Code[20]) { } - // Breaking: the published "Email" field was renamed in place. Dependent - // extensions that reference "Email" stop compiling, and the data stored in - // the old column is orphaned on upgrade. + // Breaking: the published field was renamed while retaining ID 2. + // AppSourceCop AS0005 rejects the compatibility change; retaining the ID + // does not by itself mean the stored column was dropped and re-created. field(2; "Contact Email"; Text[80]) { } } } diff --git a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md index e1e7116..0d5a289 100644 --- a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md +++ b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md @@ -7,20 +7,20 @@ countries: [w1] application-area: [all] --- -# Obsolete published table fields instead of deleting or renaming them +# Obsolete published table fields instead of deleting or renumbering them ## Description -A table field that has shipped carries two contracts at once: extensions reference it by name, and the database holds data in its column. Deleting the field, or renaming it (which the platform treats as drop-plus-add), breaks dependent code at compile time and discards the stored data — a silent data-loss event on upgrade. The fix is the same staged lifecycle used for objects: set `ObsoleteState = Pending` together with `ObsoleteReason` and an `ObsoleteTag` naming the target version, ship the new field alongside, migrate data during the window, and only switch the old field to `ObsoleteState = Removed` in a later release once nothing depends on it. LLMs often "tidy" a schema by renaming a field in place, not realizing this is both a breaking change and a data-loss risk. +A shipped table field carries both a source-level contract and persisted data. Renaming a field while retaining its ID is prohibited by AppSourceCop AS0005 and can break dependent extensions, but it is not inherently a drop-and-readd operation and should not be described as automatic data loss. Deleting the field or replacing it under a different ID is the data-loss risk: the old field storage is no longer represented unless data is migrated. The supported path is to keep the old field and obsolete it, add a replacement under a new ID, and migrate values before later removal. ## Best Practice -Add the replacement field, then mark the old field `ObsoleteState = Pending` with an `ObsoleteReason` that names the replacement and an `ObsoleteTag` carrying the target version (for example `'25.0'`). Keep the obsolete field readable so an upgrade codeunit can copy its data into the new field during the deprecation window. Move it to `ObsoleteState = Removed` only in a later major version, after the window has passed and data has migrated. +Add the replacement field under a new ID, then mark the old field `ObsoleteState = Pending` with an `ObsoleteReason` that names the replacement and an `ObsoleteTag` recording the obsoletion version. Keep the old field readable so an upgrade codeunit can copy its data during the deprecation window. Move it to `ObsoleteState = Removed` only in a later release, after the window has passed and data has migrated. See sample: `obsolete-table-fields-instead-of-deleting-them.good.al`. ## Anti Pattern -Renaming the published `Email` field to `Contact Email` directly in the table — or deleting it — so dependent extensions that reference `Email` break and the column's stored values are orphaned on upgrade. Detection: a previously shipped field removed or renamed in a table or table extension with no `ObsoleteState = Pending` step preserving the original. Obsolete the field through the lifecycle instead. +Renaming published `Email` to `Contact Email` with the same ID violates the compatibility contract and AS0005, even though the retained ID does not itself imply a fresh empty column. Deleting `Email` or moving the replacement to another ID without migration additionally risks losing its stored values. Detection: a previously shipped field removed, renumbered, or renamed with no retained `Pending` field and migration path. See sample: `obsolete-table-fields-instead-of-deleting-them.bad.al`. diff --git a/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.good.al b/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.good.al index dcd64b9..808dc69 100644 --- a/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.good.al +++ b/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.good.al @@ -15,10 +15,13 @@ codeunit 50185 "Collect Errors Good Sample" until Item.Next() = 0; if HasCollectedErrors() then begin - CollectedErrors := GetCollectedErrors(); + // The default is false; true retrieves and clears the collection. + CollectedErrors := GetCollectedErrors(true); + // This blocking aggregate intentionally retains messages only. foreach CollectedError in CollectedErrors do ErrorText += CollectedError.Message() + '\'; - Message('The following must be fixed before posting:\%1', ErrorText); + Error(ErrorInfo.Create( + StrSubstNo('The following must be fixed before posting:\%1', ErrorText), false)); end; end; } @@ -30,8 +33,10 @@ codeunit 50186 "Collect Errors Item Check" trigger OnRun() begin if Rec.Description = '' then - Error('Item %1 has no description.', Rec."No."); + Error(ErrorInfo.Create( + StrSubstNo('Item %1 has no description.', Rec."No."), true)); if Rec."Unit Cost" <= 0 then - Error('Item %1 must have a positive unit cost.', Rec."No."); + Error(ErrorInfo.Create( + StrSubstNo('Item %1 must have a positive unit cost.', Rec."No."), true)); end; } diff --git a/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md b/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md index 6cc891c..b464fec 100644 --- a/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md +++ b/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [19..] domain: error-handling keywords: [collectible-errors, errorbehavior, collect, getcollectederrors, hascollectederrors, validation, batch] technologies: [al] @@ -11,16 +11,16 @@ application-area: [all] ## Description -By default a procedure stops on the first `Error`, so a user fixing ten bad rows must rerun the operation ten times. The collectible-errors feature postpones error handling to the end of the call: a procedure attributed `[ErrorBehavior(ErrorBehavior::Collect)]` keeps running as errors occur and gathers them, so all failures can be presented together. The collected errors are read with `HasCollectedErrors()` and `GetCollectedErrors()` (which returns a `List of [ErrorInfo]`); `ClearCollectedErrors()` empties the buffer. This is a platform mechanism most LLMs are unaware of — they reach for a manually concatenated `Text` buffer or a temporary error table instead. +By default a procedure stops on the first `Error`, so a user fixing ten bad rows must rerun the operation ten times. The collectible-errors feature postpones error handling to the end of the call: a procedure attributed `[ErrorBehavior(ErrorBehavior::Collect)]` keeps running as collectible errors occur and gathers them, so all failures can be presented together. `GetCollectedErrors()` returns a `List of [ErrorInfo]` for the handler to inspect, but does not clear the collection by default; pass `true` to retrieve and clear in one call, or call `ClearCollectedErrors()` explicitly after retrieving. A handler can copy record information into a custom error page as Microsoft Learn demonstrates, or deliberately format only the messages into a final blocking error as this article's sample does. ## Best Practice -Mark the orchestrating procedure `[ErrorBehavior(ErrorBehavior::Collect)]` and run each item's validation so one failure doesn't abandon the rest — typically by calling the per-item routine through `Codeunit.Run`. When the run finishes, inspect `HasCollectedErrors()` and surface `GetCollectedErrors()` to the user as a single list. Always handle the collected errors yourself: the platform's own guidance is that any errors still in the collected list when the procedure ends are concatenated into one dialog, which is hard for users to read. +Mark the orchestrating procedure `[ErrorBehavior(ErrorBehavior::Collect)]` and run each item's validation so one failure doesn't abandon the rest — typically by calling the per-item routine through `Codeunit.Run`. When the run finishes, inspect `HasCollectedErrors()`, retrieve and clear the list with `GetCollectedErrors(true)`, and fail the operation with the collected messages. The sample intentionally produces a text aggregate and does not claim to retain record/field metadata in the final error. If that metadata is needed, map each `ErrorInfo` to a custom error UI before clearing, following the Microsoft Learn pattern. Do not replace validation failure with `Message`: clearing collected errors suppresses the platform failure, so the custom handler must still block the invalid operation. See sample: `collect-validation-errors-with-errorbehavior.good.al`. ## Anti Pattern -Two shapes signal trouble. The first is hand-rolled accumulation — appending messages to a `Text` variable and showing them at the end — which reimplements the platform feature, loses each error's `ErrorInfo` structure, and skips telemetry classification. The second is applying `[ErrorBehavior(ErrorBehavior::Collect)]` but never calling `HasCollectedErrors`/`GetCollectedErrors`, so every collected error spills into the platform's concatenated end-of-procedure dialog. Detection: a `Collect` attribute with no matching `GetCollectedErrors` call, or a per-row loop that builds an error string by concatenation. +Three shapes signal trouble. Hand-rolled accumulation reimplements collection and prevents the handler from receiving individual `ErrorInfo` values. A `Collect` procedure that never handles the collection falls back to the concatenated platform dialog. Finally, code that calls parameterless `GetCollectedErrors()`, assumes it cleared the list, and only shows a `Message` can both leave the errors collected and allow invalid processing to continue. See sample: `collect-validation-errors-with-errorbehavior.bad.al`. diff --git a/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.bad.al b/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.bad.al index 8c93c25..f9bf0ef 100644 --- a/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.bad.al +++ b/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.bad.al @@ -8,9 +8,9 @@ codeunit 50291 "New OnBefore Bad Sample" begin Total := 100; - // Anti-pattern: IsHandled was bolted onto the existing - // OnAfterCalculateTotal, changing its contract and breaking every - // subscriber that matched the original signature. + // Anti-pattern: IsHandled was bolted onto the existing OnAfter event. + // Regardless of compiler compatibility, this changes a notification + // into an override contract that existing subscribers did not expect. OnAfterCalculateTotal(SalesHeader, Total, IsHandled); end; diff --git a/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.good.al b/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.good.al index 0d64906..8063c9d 100644 --- a/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.good.al +++ b/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.good.al @@ -8,13 +8,13 @@ codeunit 50260 "Reuse Event Good Sample" IsHandled := false; // A single event, extended with CustomerNo appended at the end, covers // the need; no second event is raised beside it. - OnBeforeProcessOrder(SalesHeader, CustomerNo, IsHandled); + OnBeforeProcessOrder(SalesHeader, IsHandled, CustomerNo); if IsHandled then exit; end; [IntegrationEvent(false, false)] - local procedure OnBeforeProcessOrder(var SalesHeader: Record "Sales Header"; CustomerNo: Code[20]; var IsHandled: Boolean) + local procedure OnBeforeProcessOrder(var SalesHeader: Record "Sales Header"; var IsHandled: Boolean; CustomerNo: Code[20]) begin end; } diff --git a/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.good.al b/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.good.al index ef67a3e..b7e3893 100644 --- a/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.good.al +++ b/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.good.al @@ -20,13 +20,14 @@ codeunit 50225 "Reservation Post Good Sample" var IsHandled: Boolean; begin + IsHandled := false; OnBeforeReserve(ReservationEntry, IsHandled); - if IsHandled then - exit; - - ReservationEntry.Reserved := true; - ReservationEntry.Modify(true); + if not IsHandled then begin + ReservationEntry.Reserved := true; + ReservationEntry.Modify(true); + end; + // OnAfter reports completion whether a subscriber or the base body handled it. OnAfterReserve(ReservationEntry); end; diff --git a/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.good.al b/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.good.al index 6b47535..07913fa 100644 --- a/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.good.al +++ b/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.good.al @@ -7,6 +7,7 @@ codeunit 50220 "Shipping Charge Good Sample" begin // Give extensions a sanctioned seam to replace the calculation, then // skip the default logic when a subscriber has handled it. + IsHandled := false; OnBeforeCalculateShippingCharge(OrderAmount, Charge, IsHandled); if IsHandled then exit(Charge); diff --git a/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md b/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md index 92ef3cf..af7d5e8 100644 --- a/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md +++ b/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md @@ -15,7 +15,7 @@ An `enum` that `implements` an interface maps each value to a codeunit through t ## Best Practice -On any extensible enum that implements an interface, set `DefaultImplementation = = ;` at the enum level, pointing at a safe implementation that does nothing harmful. Values with their own `Implementation` keep using it; every other value — including ones added later by extensions — resolves to the default instead of failing. For the distinct case of an out-of-range integer that matches no declared value, pair it with `UnknownValueImplementation`. The result is that a consumer can assign any enum value to the interface variable and call through it without a runtime guard. +On any extensible enum that implements an interface, set `DefaultImplementation = = ;` at the enum level, pointing at a safe implementation that does nothing harmful. Values with their own `Implementation` keep using it; declared values without one resolve to the default. For an ordinal that matches no currently declared value — for example persisted data left after an enum extension is uninstalled — runtime 7.0 and later can use `UnknownValueImplementation` as a distinct fallback. Do not recommend that property to apps targeting an earlier runtime. See sample: `set-defaultimplementation-on-enum.good.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 index c30ae3b..4b5a26d 100644 --- a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al +++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al @@ -5,6 +5,23 @@ codeunit 50154 "Test Sample TransModel Bad" [Test] [TransactionModel(TransactionModel::AutoRollback)] procedure TestPostingRoutineAutoRollback() + var + PostingRoutine: Codeunit "Posting Routine Commit Bad"; begin + // Runtime error: AutoRollback forbids the Commit reached below. + PostingRoutine.PostCustomer(); + end; +} + +codeunit 50156 "Posting Routine Commit Bad" +{ + procedure PostCustomer() + var + Customer: Record Customer; + begin + Customer.Init(); + Customer."No." := 'T-BADCOMMIT'; + Customer.Insert(true); + Commit(); 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 index f977a94..7a19a61 100644 --- a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al +++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al @@ -16,6 +16,22 @@ codeunit 50153 "Test Sample TransModel Good" [Test] [TransactionModel(TransactionModel::AutoCommit)] procedure TestLogicThatCommitsInternally() + var + PostingRoutine: Codeunit "Posting Routine With Commit"; begin + PostingRoutine.PostCustomer(); + end; +} + +codeunit 50155 "Posting Routine With Commit" +{ + procedure PostCustomer() + var + Customer: Record Customer; + begin + Customer.Init(); + Customer."No." := 'T-COMMIT'; + Customer.Insert(true); + Commit(); end; } diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md index 084fccd..ab89a96 100644 --- a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md +++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md @@ -15,7 +15,7 @@ application-area: [all] ## 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. +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 make the test exercise that commit path. Pair the test 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. See sample: `transactionmodel-attribute-governs-test-transactions.good.al`. diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al index 800c828..31e8371 100644 --- a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al @@ -7,8 +7,8 @@ codeunit 50221 "Upgrade Existing Field" 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. + // DataTransfer skips the field's OnValidate logic and validation events, + // plus the table OnModify trigger and row-based modification events. DT.SetTables(Database::Customer, Database::Customer); DT.AddConstantValue(50000, Customer.FieldNo("Credit Limit (LCY)")); DT.CopyFields(); diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al index 0475079..7dbf24c 100644 --- a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al @@ -1,16 +1,17 @@ -codeunit 50220 "Upgrade New Field Init" +codeunit 50220 "Upgrade Trigger Aware" { Subtype = Upgrade; - local procedure InitializeNewFlagOnMyTable() + local procedure UpdateCustomerCreditLimit() var - MyTable: Record "My Table"; - DT: DataTransfer; + Customer: Record Customer; 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(); + if Customer.FindSet(true) then + repeat + // Validate runs the field OnValidate logic; Modify(true) separately + // runs the table OnModify trigger and its row-based events. + Customer.Validate("Credit Limit (LCY)", 50000); + Customer.Modify(true); + until Customer.Next() = 0; end; } diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md index 49d9ab1..34a406b 100644 --- a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md @@ -11,18 +11,18 @@ application-area: [all] ## 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. +`DataTransfer` writes sets directly at the database layer, so row-based triggers and events do not run. For `CopyFields`, that includes the table `OnModify` trigger and `OnBeforeModifyEvent`/`OnAfterModifyEvent`; direct field assignment also does not call field `OnValidate` or its validation events. These are separate behaviors: `Record.Validate(Field, Value)` runs field validation, while `Record.Modify(true)` runs the table `OnModify` trigger. Calling `Modify(true)` does not retroactively validate assigned fields. 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. +Use `DataTransfer` when set-based transfer is safe and row-level business logic is intentionally unnecessary — initial population of a new field is the canonical case. When an existing field's validation must run, loop through records and call `Validate(Field, Value)`; if the table's modify trigger must also run, follow with `Modify(true)`. If performance requires `DataTransfer`, document exactly which field-validation and row-modification triggers or subscribers are intentionally bypassed and verify that derived data remains correct. 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. +Reaching for `DataTransfer` to update an existing field with non-trivial `OnValidate` or `OnModify` logic, without confirming that both validation and row-modification subscribers can be skipped. Replacing it with only `Modify(true)` is also incomplete when field validation is required; call `Validate` for that field first. See sample: `datatransfer-skips-triggers-and-subscribers.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 index 22290a4..578db0b 100644 --- a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al +++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al @@ -1,7 +1,7 @@ codeunit 50228 "Old Method Holder" { - // ObsoleteState set without ObsoleteReason or ObsoleteTag. - [Obsolete('')] + // Methods use the attribute, but empty reason and tag give no migration path. + [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 index 8562b0c..0a0602a 100644 --- a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al +++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al @@ -3,7 +3,7 @@ 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. + // The method remains callable during its deprecation window. end; procedure NewMethod() diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md index 0f2e11e..d6ec37a 100644 --- a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md +++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md @@ -7,27 +7,26 @@ countries: [w1] application-area: [all] --- -# Mark obsolete elements with `ObsoleteState`, `ObsoleteReason`, and `ObsoleteTag` +# Give every obsolete element a reason and tag ## Description -When a procedure, field, table, page, or enum value is being retired, AL requires three pieces of metadata to declare the deprecation: +AL has two obsoletion mechanisms, depending on the symbol: -- `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'`). +- Objects, fields, enum types, and enum values use the `ObsoleteState`, `ObsoleteReason`, and `ObsoleteTag` properties. `Pending` warns while the element remains available; `Removed` blocks references. +- Methods, variables, events, and other symbols use `[Obsolete('reason', 'tag')]`. They do not have an `ObsoleteState` property. -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. +In both forms, the reason should name the replacement and the tag should identify when the element became obsolete. Empty or missing guidance leaves consumers without an actionable migration path. ## 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. +For an object or field, set all three properties together. For a method, variable, or event, provide both `[Obsolete]` arguments. Keep the original tag stable through the lifecycle rather than changing it to a planned removal version. 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. +Setting only `ObsoleteState = Pending`/`Removed` on an object or field, or using `[Obsolete('', '')]` on a method, variable, or event. Both forms produce deprecation metadata without useful replacement guidance or traceability. See sample: `obsoletion-requires-reason-and-tag.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 index adf5cf5..3a50448 100644 --- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al @@ -16,5 +16,6 @@ codeunit 50213 "Upgrade Tag Registration" exit('MS-123456-MyFeature-20240101'); end; - // No OnGetPerCompanyUpgradeTags subscriber — the tag is unknown to the platform. + // No OnGetPerCompanyUpgradeTags subscriber: SetAllUpgradeTags cannot seed this + // historical step for a newly initialized company, so it can run unnecessarily. } 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 02362c9..a214717 100644 --- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al @@ -1,18 +1,6 @@ -codeunit 50212 "Upgrade Tag Registration" +codeunit 50212 "Upgrade Tag Definitions" { - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - var - UpgradeTag: Codeunit "Upgrade Tag"; - begin - if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then - exit; - // Upgrade work ... - UpgradeTag.SetUpgradeTag(MyUpgradeTag()); - end; - - local procedure MyUpgradeTag(): Code[250] + procedure MyUpgradeTag(): Code[250] begin exit('MS-123456-MyFeature-20240101'); end; @@ -23,3 +11,34 @@ codeunit 50212 "Upgrade Tag Registration" PerCompanyUpgradeTags.Add(MyUpgradeTag()); end; } + +codeunit 50214 "Upgrade Tagged Feature" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + Tags: Codeunit "Upgrade Tag Definitions"; + begin + if UpgradeTag.HasUpgradeTag(Tags.MyUpgradeTag()) then + exit; + // Upgrade work ... + UpgradeTag.SetUpgradeTag(Tags.MyUpgradeTag()); + end; +} + +codeunit 50215 "Install Tagged Feature" +{ + Subtype = Install; + + trigger OnInstallAppPerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + Tags: Codeunit "Upgrade Tag Definitions"; + begin + // Existing-company install path; new-company initialization uses + // SetAllUpgradeTags and the subscriber above. + UpgradeTag.SetUpgradeTag(Tags.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 a413520..e5e1983 100644 --- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md @@ -7,22 +7,22 @@ countries: [w1] application-area: [all] --- -# Register every upgrade tag with the platform via an event subscriber +# Register upgrade tags that must be seeded for new companies ## Description -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. +`SetUpgradeTag(Tag)` directly records a completed per-company upgrade step; `HasUpgradeTag(Tag)` can then guard that step on later upgrades. The `OnGetPerCompanyUpgradeTags` subscriber serves a different path: it contributes tags to the list used by `SetAllUpgradeTags()` when a new company is initialized, marking historical upgrade steps complete so they do not run against a company that starts on the current schema. -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. +Registration is not install-time seeding. When an extension is installed into an existing company and a tag must start as complete, the install code must call `SetUpgradeTag` explicitly. For new-company initialization, codeunit `Company Initialize` calls `SetAllUpgradeTags`, which obtains subscriber-provided per-company tags and inserts missing ones. Database-scoped upgrade steps use `HasDatabaseUpgradeTag`/`SetDatabaseUpgradeTag` and the corresponding per-database list. ## Best Practice -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. +In the upgrade codeunit, guard work with `HasUpgradeTag` and call `SetUpgradeTag` only after successful completion. Seed the same tag explicitly from `OnInstallAppPerCompany` when first-install logic should not run as a later upgrade. Also add historical per-company tags to `OnGetPerCompanyUpgradeTags` so `SetAllUpgradeTags` marks them complete for newly created companies. Keep the tag definition shared so all paths use the exact same value. See sample: `register-upgrade-tags-with-subscribers.good.al`. ## Anti Pattern -Calling `UpgradeTag.SetUpgradeTag(MyUpgradeTag())` without ever adding `MyUpgradeTag()` to the corresponding `OnGetPerCompany...` / `OnGetPerDatabase...` subscriber. +Assuming an `OnGetPerCompanyUpgradeTags` subscriber sets tags during extension installation, or omitting the subscriber and allowing old upgrade steps to run when `SetAllUpgradeTags` initializes a new company. The subscriber supplies a list; only `SetAllUpgradeTags` or an explicit `SetUpgradeTag` call persists it. See sample: `register-upgrade-tags-with-subscribers.bad.al`. diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al index 024443c..27c972c 100644 --- a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al +++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al @@ -1,7 +1,7 @@ codeunit 50201 "Upgrade My Feature" { - // Missing Subtype = Upgrade; the OnUpgrade trigger is never dispatched. - trigger OnUpgradePerCompany() + // This compiles, but no Subtype = Upgrade trigger wires it to the pipeline. + procedure RunUpgrade() begin UpgradeMyFeature(); end; diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md index 2dba21a..a9fee7c 100644 --- a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md +++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md @@ -11,7 +11,7 @@ application-area: [all] ## 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. +A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upgrade`. The platform then permits and dispatches the `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on that codeunit during upgrade. A normal codeunit can contain an upgrade-like `RunUpgrade` procedure, but the platform does not discover or invoke it automatically. Conversely, any procedure invoked transitively from an `OnUpgrade...` trigger of an upgrade codeunit is upgrade code regardless of where the helper lives, and the upgrade rules apply to it. ## Best Practice diff --git a/microsoft/knowledge/web-services/set-required-api-page-properties.bad.al b/microsoft/knowledge/web-services/set-required-api-page-properties.bad.al index 927bc2d..c248cb2 100644 --- a/microsoft/knowledge/web-services/set-required-api-page-properties.bad.al +++ b/microsoft/knowledge/web-services/set-required-api-page-properties.bad.al @@ -1,12 +1,13 @@ -// Malformed API endpoint: APIPublisher and APIGroup are missing, and there is -// no SourceTable. The page compiles but the route cannot be composed, so the -// entity is never published where an integration expects it. +// APIVersion is omitted. This is valid, but the endpoint defaults to beta +// instead of publishing the intended explicit stable contract. page 50341 "WS Required Props Bad" { PageType = API; - APIVersion = 'v1.0'; + APIPublisher = 'contoso'; + APIGroup = 'sales'; EntityName = 'customer'; EntitySetName = 'customers'; + SourceTable = Customer; layout { diff --git a/microsoft/knowledge/web-services/set-required-api-page-properties.md b/microsoft/knowledge/web-services/set-required-api-page-properties.md index 9bef346..496db1a 100644 --- a/microsoft/knowledge/web-services/set-required-api-page-properties.md +++ b/microsoft/knowledge/web-services/set-required-api-page-properties.md @@ -7,20 +7,20 @@ countries: [w1] application-area: [all] --- -# Declare every required property on a PageType = API page +# Declare API routing properties and an explicit stable version ## Description -An API page projects a table as an OData v4 / API v2 endpoint, but the platform only publishes that endpoint when the page carries the full set of identifying properties: `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, and a backing `SourceTable`. These properties are what compose the route — `/api////` — so omitting any one of them yields a page that compiles yet never surfaces as a usable endpoint, or surfaces at an unexpected address. An LLM that has mostly seen ordinary list/card pages tends to treat `PageType = API` as a cosmetic switch and forgets the identifying metadata, because a normal page needs none of it. This file is remedial precisely because the missing-property failure is silent: there is no runtime error, only an endpoint that clients cannot reach. +An API page needs `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, and a backing `SourceTable` to define its routed entity. `APIVersion` is different: it is optional at the language level and defaults to `beta`. Omitting it therefore does not mean the page has no version; it publishes under the preview contract. A production integration that intends a stable route should set a `vX.Y` version explicitly rather than rely on that default. ## Best Practice -On every `PageType = API` page set all six properties explicitly: `APIPublisher` (your publisher tag), `APIGroup` (the logical grouping for related entities), `APIVersion` (a `vX.Y` value such as `'v1.0'`), `EntityName` (singular), `EntitySetName` (plural), and `SourceTable` (the projected table). Expose the record's fields inside a single `field(...)` repeater under `area(content)`. Treat the six properties as a mandatory checklist that travels with the `PageType = API` declaration itself. +Declare the five routing/entity properties required by the API page and set `APIVersion` explicitly for a stable published contract, for example `'v1.0'`. Expose the record's fields inside a repeater under `area(content)`. Review missing routing metadata as a malformed API definition, but review a missing `APIVersion` as unintended publication under `beta`, not as an unpublished endpoint. See sample: `set-required-api-page-properties.good.al`. ## Anti Pattern -Writing a page with `PageType = API` and a `SourceTable` but leaving out `APIPublisher` and `APIGroup` (and, worse, omitting `SourceTable` entirely). The page compiles, so it looks finished, but the endpoint is malformed: with no publisher and group the route cannot be composed, and the entity is never published where an integration expects it. The detection signal: a `PageType = API` page missing one or more of the six identifying properties. +Leaving out `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, or `SourceTable` leaves the API definition incomplete. A subtler contract defect is declaring all of those but omitting `APIVersion`: the page is exposed as `beta`, which is valid runtime behavior but not the explicit stable route a production client expects. See sample: `set-required-api-page-properties.bad.al`. diff --git a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.good.al b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.good.al index 97aeb3a..9f1246f 100644 --- a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.good.al +++ b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.good.al @@ -1,13 +1,11 @@ -// Additive versioning: v2.0 carries the new shape while v1.0 stays published and -// unchanged. APIVersion accepts a list, so both contracts are served and -// existing clients keep working while new clients adopt v2.0. -page 50354 "WS API Versioning Good" +// The original page remains the unchanged v1.0 contract. +page 50354 "Customer API v1" { PageType = API; Caption = 'customer'; APIPublisher = 'contoso'; APIGroup = 'sales'; - APIVersion = 'v2.0', 'v1.0'; + APIVersion = 'v1.0'; EntityName = 'customer'; EntitySetName = 'customers'; ODataKeyFields = SystemId; @@ -37,3 +35,41 @@ page 50354 "WS API Versioning Good" } } } + +// A separate object carries the changed v2.0 shape. +page 50356 "Customer API v2" +{ + PageType = API; + Caption = 'customer'; + APIPublisher = 'contoso'; + APIGroup = 'sales'; + APIVersion = 'v2.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + ODataKeyFields = SystemId; + SourceTable = Customer; + DelayedInsert = true; + + layout + { + area(content) + { + repeater(records) + { + field(id; Rec.SystemId) + { + Caption = 'id'; + Editable = false; + } + field(number; Rec."No.") + { + Caption = 'number'; + } + field(legalName; Rec.Name) + { + Caption = 'legalName'; + } + } + } + } +} diff --git a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md index af998ed..bfd2c9f 100644 --- a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md +++ b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md @@ -7,20 +7,20 @@ countries: [w1] application-area: [all] --- -# Version APIs by adding a new APIVersion, not by mutating a published one +# Version changed API shapes with a new page object ## Description -Once an API version is published, external clients depend on its exact shape — the entity name, the set of exposed fields, the key — as a frozen contract. Changing any of that on the already-published version is a breaking change delivered silently: integrations that worked yesterday fail today with no warning. The platform gives you a clean way to evolve without breaking anyone, because `APIVersion` accepts a *list* of versions on one page. The correct way to change a published API is to add the new version (`'v2.0'`) alongside the existing one (`'v1.0'`) — or publish a new API page for it — so both contracts are served side by side and clients migrate on their own schedule. LLMs tend to "fix" an API by editing the live version in place, because in ordinary code you just change what's wrong; this file is remedial because a published API version is an immutable contract in a way ordinary internal code is not. +Once an API version is published, external clients depend on its exact shape — entity names, fields, keys, and behavior — as a stable contract. `APIVersion` can list several versions on one API page, but every listed route is generated from that same page object and therefore exposes the same shape. Adding `'v2.0'` to a page and then changing its fields changes what both `v1.0` and `v2.0` serve. To preserve the v1 shape while introducing a different v2 shape, keep the v1 page unchanged and create a separate page object for v2. ## Best Practice -When a published API must change shape, keep the old version's contract intact and add the new one to the `APIVersion` list — `APIVersion = 'v2.0', 'v1.0';`. The page now serves both `v1.0` (unchanged) and `v2.0` (carrying the new shape), so existing clients keep working while new clients adopt `v2.0`. Retire the old version only after consumers have migrated. +Keep the existing page object and its `APIVersion = 'v1.0'` contract unchanged. Copy the page to a new object ID, set that object's `APIVersion = 'v2.0'`, and make the v2-only shape changes there. A multi-value `APIVersion` list is appropriate only when the exact same page shape is supported under each listed version. See sample: `version-apis-by-adding-not-mutating-published-versions.good.al`. ## Anti Pattern -Editing the published `v1.0` page in place — renaming its `EntityName` or removing an exposed field — so the single declared version now serves a different contract than the one clients integrated against. Every consumer of the old shape breaks without notice. The detection signal: a change that renames the entity or removes a field on an existing published `APIVersion` instead of adding a new version to the list. +Editing the published `v1.0` page in place breaks its clients. So does adding `v2.0` to that same page and assuming subsequent field changes apply only to v2: both routes use one object shape. The detection signal is a breaking shape change without a separate API page object retaining the old version. See sample: `version-apis-by-adding-not-mutating-published-versions.bad.al`. diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md index 4109722..f2e6e6a 100644 --- a/microsoft/skills/review/al-web-services-review.md +++ b/microsoft/skills/review/al-web-services-review.md @@ -84,14 +84,14 @@ Output conforms to the DO output contract. A populated example: "skill": { "id": "al-web-services-review", "version": 1 }, "outcome": "completed", "summary": { - "counts": { "blocker": 0, "major": 1, "minor": 1, "info": 0 }, + "counts": { "blocker": 0, "major": 0, "minor": 2, "info": 0 }, "coverage": { "worklist-size": 2, "items-evaluated": 2 } }, "findings": [ { "id": "microsoft/knowledge/web-services/set-required-api-page-properties.md", - "severity": "major", - "message": "This PageType = API page declares a SourceTable but omits APIPublisher and APIGroup, so the endpoint route cannot be composed and the entity is never published. Declare all six required API page properties.", + "severity": "minor", + "message": "This PageType = API page omits APIVersion, so it is exposed under beta by default rather than an explicit stable contract. Declare the intended version, such as APIVersion = 'v1.0'.", "location": { "file": "src/Api/CustomerApi.Page.al", "line": 3, From 0e06485027b3cfa8bff9f2eadbd3cb845846495a Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 14 Jul 2026 11:26:29 +0200 Subject: [PATCH 16/86] Correct performance knowledge guidance (#94) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 667c64a8-eb36-4440-bc41-6a97d8fb5542 Co-authored-by: Jesper Schulz-Wedde --- ...dloadfields-in-report-onpredataitem.bad.al | 13 +++++- ...loadfields-in-report-onpredataitem.good.al | 13 +++++- .../addloadfields-in-report-onpredataitem.md | 8 ++-- .../avoid-commit-inside-loops.good.al | 35 +++++++++------ .../performance/avoid-commit-inside-loops.md | 3 +- ...void-get-inside-loop-on-large-table.bad.al | 12 +++--- ...oid-get-inside-loop-on-large-table.good.al | 43 ++++++++++++++----- .../avoid-get-inside-loop-on-large-table.md | 6 +-- ...e-maintainsiftindex-by-read-write-ratio.md | 4 +- ...mon-fields-before-branching-on-case.bad.al | 33 ++++++++------ ...on-fields-before-branching-on-case.good.al | 35 +++++++++------ ...-common-fields-before-branching-on-case.md | 6 +-- ...lter-only-fields-from-setloadfields.bad.al | 24 ----------- ...ter-only-fields-from-setloadfields.good.al | 22 ---------- ...t-filter-only-fields-from-setloadfields.md | 28 ------------ .../order-case-branches-by-frequency.good.al | 8 ++-- .../order-case-branches-by-frequency.md | 6 +-- ...ionary-over-temporary-table-for-lookups.md | 6 +-- ...refer-modifyall-over-per-row-modify.bad.al | 30 +++++++++---- ...efer-modifyall-over-per-row-modify.good.al | 34 +++++++++------ .../prefer-modifyall-over-per-row-modify.md | 8 ++-- ...disolation-over-locktable-for-reads.bad.al | 16 +++---- ...isolation-over-locktable-for-reads.good.al | 14 +++--- ...-readisolation-over-locktable-for-reads.md | 6 +-- ...ion-scale-tables-warrant-extra-analysis.md | 22 ---------- ...etup-tables-need-no-access-optimization.md | 8 ++-- .../temporary-tables-have-no-database-cost.md | 8 ++-- ...eleteall-for-filtered-bulk-deletion.bad.al | 38 ++++++++++------ ...leteall-for-filtered-bulk-deletion.good.al | 37 ++++++++++------ ...se-deleteall-for-filtered-bulk-deletion.md | 6 +-- ...ilder-for-string-concatenation-in-loops.md | 8 ++-- 31 files changed, 279 insertions(+), 261 deletions(-) delete mode 100644 microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.bad.al delete mode 100644 microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.good.al delete mode 100644 microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.md delete mode 100644 microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md diff --git a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al index 2fd95ac..69b81f3 100644 --- a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al +++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al @@ -2,13 +2,22 @@ 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) { } + + trigger OnAfterGetRecord() + begin + // Source Code is not a dataset column, so its first access causes a + // just-in-time load and updates the dataitem enumerator. + RegisterSourceCode("Source Code"); + end; } } + + local procedure RegisterSourceCode(SourceCode: Code[10]) + begin + end; } diff --git a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al index 3267418..e235a61 100644 --- a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al +++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al @@ -10,8 +10,19 @@ report 50220 "Perf Sample AddLoadFields Good" trigger OnPreDataItem() begin - AddLoadFields("Customer No.", "Posting Date", Amount); + // Dataset columns are selected by the report compiler. Source Code is + // extra because only trigger code reads it. + CustLedgerEntry.AddLoadFields("Source Code"); + end; + + trigger OnAfterGetRecord() + begin + RegisterSourceCode("Source Code"); end; } } + + local procedure RegisterSourceCode(SourceCode: Code[10]) + begin + end; } diff --git a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md index aa811ce..683a8a0 100644 --- a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md +++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md @@ -7,20 +7,20 @@ countries: [w1] application-area: [all] --- -# In reports, declare the fields the layout needs with AddLoadFields +# Add trigger-only report fields in OnPreDataItem ## 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. +Report dataitem field selection is calculated at compile time and once per dataitem type during execution. Fields referenced by dataset columns are selected automatically; fields used only in triggers are not. Use `AddLoadFields` in `OnPreDataItem` to supplement the automatic selection with normal fields that trigger code needs. ## 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. +When a dataitem trigger needs an extra field, add that field in `OnPreDataItem` before iteration starts. This supplements the compiler-selected fields and avoids the first just-in-time load and enumerator update when the trigger reads the extra field. 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. +Listing every dataset column in `AddLoadFields`, or omitting a known trigger-only field because the dataset already uses other fields. The former is redundant; the latter causes a just-in-time load on first access and can cause repeated loads when the record is copied or passed by value. See sample: `addloadfields-in-report-onpredataitem.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al b/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al index afd57a2..e8b55b2 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al @@ -1,21 +1,32 @@ codeunit 50128 "Perf Sample CommitInLoop Good" { procedure NormalizeCustomerNames() + var + LastCustomerNo: Code[20]; + begin + // The outer loop owns checkpoints; the per-row loop contains no Commit. + while NormalizeNextChunk(LastCustomerNo, 500) do + Commit(); + end; + + local procedure NormalizeNextChunk(var LastCustomerNo: Code[20]; ChunkSize: Integer): Boolean 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; + Customer.SetCurrentKey("No."); + if LastCustomerNo <> '' then + Customer.SetFilter("No.", '>%1', LastCustomerNo); + if not Customer.FindSet(true) then + exit(false); + + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); + LastCustomerNo := Customer."No."; + RowsInChunk += 1; + until (RowsInChunk >= ChunkSize) or (Customer.Next() = 0); + + exit(true); end; } diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.md b/microsoft/knowledge/performance/avoid-commit-inside-loops.md index 98e0c38..4dd5b11 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.md +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.md @@ -17,7 +17,7 @@ Commit ends the current write transaction. Calling it inside a per-row loop prod ## 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. Wrapping each chunk in `Codeunit.Run` gives the same effect with native rollback on failure — see `codeunit-run-as-atomic-sub-operation.md`. +If the batch is large enough that a single transaction is untenable, use an outer loop that selects and finishes the next N rows. Commit only after the inner row loop has returned and the checkpoint state identifies where the next chunk starts. A `Codeunit.Run` boundary can also own a chunk when its implicit commit and error behavior fit the caller — see `codeunit-run-as-atomic-sub-operation.md`. See sample: `avoid-commit-inside-loops.good.al`. @@ -26,4 +26,3 @@ See sample: `avoid-commit-inside-loops.good.al`. Placing Commit inside `repeat ... until Next() = 0` is almost always a mistake: it is unusual for the correctness of the operation to depend on per-row commits, and the cost of starting a new transaction on every row dominates the work. See sample: `avoid-commit-inside-loops.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 index 7ff67be..8066218 100644 --- 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 @@ -1,15 +1,17 @@ codeunit 50253 "Perf Sample NPlus1 Bad" { - procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal + procedure SumStdCost(BOMNo: Code[20]; BOMVersionCode: Code[20]) TotalCost: Decimal var + BOMLine: Record "Production BOM Line"; Item: Record Item; begin + BOMLine.SetRange("Production BOM No.", BOMNo); + BOMLine.SetRange("Version Code", BOMVersionCode); 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"; + 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.good.al b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al index 2bdbf65..dbd98d8 100644 --- 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 @@ -1,15 +1,38 @@ -codeunit 50252 "Perf Sample NPlus1 Good" +query 50252 "Perf Sample BOM Cost" { - procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal + QueryType = Normal; + + elements + { + dataitem(ProductionBOMLine; "Production BOM Line") + { + column(ProductionBOMNo; "Production BOM No.") { } + column(VersionCode; "Version Code") { } + column(QuantityPer; "Quantity per") { } + + dataitem(Item; Item) + { + DataItemLink = "No." = ProductionBOMLine."No."; + DataItemTableFilter = "Costing Method" = const(Standard); + SqlJoinType = InnerJoin; + + column(StandardCost; "Standard Cost") { } + } + } + } +} + +codeunit 50254 "Perf Sample NPlus1 Good" +{ + procedure SumStdCost(BOMNo: Code[20]; BOMVersionCode: Code[20]) TotalCost: Decimal var - Item: Record Item; + BOMCost: Query "Perf Sample BOM Cost"; 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; + BOMCost.SetRange(ProductionBOMNo, BOMNo); + BOMCost.SetRange(VersionCode, BOMVersionCode); + BOMCost.Open(); + while BOMCost.Read() do + TotalCost += BOMCost.StandardCost * BOMCost.QuantityPer; + BOMCost.Close(); 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 index 2908d3f..f77fbfe 100644 --- a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md +++ b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md @@ -11,16 +11,16 @@ application-area: [all] ## 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. +A `Get` or `FindFirst` against another persistent table inside a loop can produce an N+1 access pattern: one outer query followed by repeated inner lookups. Server and primary-key caches can satisfy some `Get` calls, so a source-level `Get` is not proof of one SQL round-trip. The concern is an unbounded loop whose lookup keys are not known to repeat or remain cached. ## 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. +Use a query object to join the outer and inner tables when the relationship and filters can be expressed as one query. If keys repeat, a dictionary cache can reduce lookups to one per distinct key. `SetLoadFields` can reduce the columns transferred by unavoidable inner reads, but it does not eliminate the N+1 shape and must not be presented as doing so. 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. +Iterating production BOM lines and calling `Item.Get(BOMLine."No.")` for each line when the same result can be produced by a query joining Production BOM Line to Item. Partial loading alone is only a payload mitigation for this pattern. See sample: `avoid-get-inside-loop-on-large-table.bad.al`. diff --git a/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md b/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md index f320dbc..3261a2c 100644 --- a/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md +++ b/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md @@ -13,11 +13,11 @@ application-area: [all] ## Description -`MaintainSIFTIndex` on a key decides whether the SIFT aggregate structure is updated on every `INSERT`, `MODIFY`, and `DELETE` that touches the key's fields. With `Yes`, `CalcSums` and FlowField reads are immediate — but every write pays the cost of updating the aggregate. With `No`, writes are cheaper but the first aggregate read after a change has to rebuild. Neither value is universally correct; the right choice depends on how often the aggregate is read versus how often the underlying rows are written. +`MaintainSIFTIndex` on a key decides whether SQL Server maintains the SIFT indexed view as underlying rows change. With `Yes`, writes that affect the key or sum fields also maintain the indexed aggregate. With `No`, that SIFT indexed view is not maintained, so a compatible `CalcSums` or FlowField calculation is computed from the base table instead and may require scanning many rows. There is no deferred "first read rebuild" of the SIFT structure. ## Best Practice -Measure read-to-write ratios for the key's SIFT fields under realistic workloads. Set `MaintainSIFTIndex = Yes` only on keys whose aggregates are read far more often than the rows are written (reporting keys on reference tables, dashboards). Set `No` on keys whose rows are written heavily and whose aggregates are read rarely (transactional ledger entries, import-staging tables). +Measure aggregate-read latency and write cost under realistic filters and volumes. Keep `MaintainSIFTIndex = true` when the maintained aggregate materially benefits frequent `CalcSums` or FlowField reads. Consider `false` when writes dominate and the less-frequent aggregate reads can tolerate calculation from the base table. See sample: `choose-maintainsiftindex-by-read-write-ratio.good.al`. diff --git a/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.bad.al b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.bad.al index 22d6de9..dae063c 100644 --- a/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.bad.al +++ b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.bad.al @@ -1,23 +1,30 @@ codeunit 50100 "Sales Document Processor" { - procedure ProcessDocument(var SalesHeader: Record "Sales Header") + procedure DescribeDocument(DocumentType: Enum "Sales Document Type"; DocumentNo: Code[20]): Text + var + SalesHeader: Record "Sales Header"; begin - // Single top-level load pulls every field any branch might touch. - // Order records pay for Posting Date and Amount Including VAT that - // only the Invoice branch reads, and vice versa. SalesHeader.SetLoadFields( - "Document Type", "No.", "Sell-to Customer No.", + "Sell-to Customer No.", "Order Date", "Shipment Date", "Completely Shipped", - "Posting Date", "Amount Including VAT"); + "Posting Date", "Due Date", "Payment Terms Code"); + SalesHeader.Get(DocumentType, DocumentNo); - case SalesHeader."Document Type" of - SalesHeader."Document Type"::Order: - ProcessOrder(SalesHeader); - SalesHeader."Document Type"::Invoice: - ProcessInvoice(SalesHeader); + case DocumentType of + DocumentType::Order: + exit(DescribeOrder(SalesHeader)); + DocumentType::Invoice: + exit(DescribeInvoice(SalesHeader)); end; end; - local procedure ProcessOrder(var SalesHeader: Record "Sales Header") begin end; - local procedure ProcessInvoice(var SalesHeader: Record "Sales Header") begin end; + local procedure DescribeOrder(SalesHeader: Record "Sales Header"): Text + begin + exit(StrSubstNo('%1|%2|%3|%4', SalesHeader."Sell-to Customer No.", SalesHeader."Order Date", SalesHeader."Shipment Date", SalesHeader."Completely Shipped")); + end; + + local procedure DescribeInvoice(SalesHeader: Record "Sales Header"): Text + begin + exit(StrSubstNo('%1|%2|%3|%4', SalesHeader."Sell-to Customer No.", SalesHeader."Posting Date", SalesHeader."Due Date", SalesHeader."Payment Terms Code")); + end; } diff --git a/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.good.al b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.good.al index ec70b4c..aeb0cf7 100644 --- a/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.good.al +++ b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.good.al @@ -1,25 +1,34 @@ codeunit 50100 "Sales Document Processor" { - procedure ProcessDocument(var SalesHeader: Record "Sales Header") + procedure DescribeDocument(DocumentType: Enum "Sales Document Type"; DocumentNo: Code[20]): Text + var + SalesHeader: Record "Sales Header"; begin - // Tier 1: the discriminator and any fields every branch reads. - SalesHeader.SetLoadFields("Document Type", "No.", "Sell-to Customer No."); + SalesHeader.SetLoadFields("Sell-to Customer No."); - case SalesHeader."Document Type" of - SalesHeader."Document Type"::Order: + case DocumentType of + DocumentType::Order: begin - // Tier 2: extend the load only on the branch that needs these fields. - SalesHeader.SetLoadFields("Order Date", "Shipment Date", "Completely Shipped"); - ProcessOrder(SalesHeader); + SalesHeader.AddLoadFields("Order Date", "Shipment Date", "Completely Shipped"); + SalesHeader.Get(DocumentType, DocumentNo); + exit(DescribeOrder(SalesHeader)); end; - SalesHeader."Document Type"::Invoice: + DocumentType::Invoice: begin - SalesHeader.SetLoadFields("Posting Date", "Amount Including VAT"); - ProcessInvoice(SalesHeader); + SalesHeader.AddLoadFields("Posting Date", "Due Date", "Payment Terms Code"); + SalesHeader.Get(DocumentType, DocumentNo); + exit(DescribeInvoice(SalesHeader)); end; end; end; - local procedure ProcessOrder(var SalesHeader: Record "Sales Header") begin end; - local procedure ProcessInvoice(var SalesHeader: Record "Sales Header") begin end; + local procedure DescribeOrder(SalesHeader: Record "Sales Header"): Text + begin + exit(StrSubstNo('%1|%2|%3|%4', SalesHeader."Sell-to Customer No.", SalesHeader."Order Date", SalesHeader."Shipment Date", SalesHeader."Completely Shipped")); + end; + + local procedure DescribeInvoice(SalesHeader: Record "Sales Header"): Text + begin + exit(StrSubstNo('%1|%2|%3|%4', SalesHeader."Sell-to Customer No.", SalesHeader."Posting Date", SalesHeader."Due Date", SalesHeader."Payment Terms Code")); + end; } diff --git a/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md index f91d72e..257b0fb 100644 --- a/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md +++ b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md @@ -13,16 +13,16 @@ application-area: [all] ## Description -When record processing branches on state, different branches typically read different fields. A single `SetLoadFields` at the top listing every field any branch might touch pulls more data than any individual execution path needs — on the hot path, the rest is loaded for nothing. A two-tier approach matches loading to actual usage: load the fields the `case` expression evaluates plus any fields every branch uses, then add a branch-local `SetLoadFields` inside each branch for that branch's extra fields. +When a known input determines which fields a subsequent record read will use, a single `SetLoadFields` containing every branch's fields loads unnecessary columns. Build the selection before `Get`, `FindFirst`, or `FindSet`: use `SetLoadFields` for fields common to every branch, then `AddLoadFields` for the selected branch. `SetLoadFields` replaces the current selection, while `AddLoadFields` preserves it. ## Best Practice -Before the `case`, call `SetLoadFields` with the minimal set — the discriminator field and fields common to every branch. Inside each branch, before the first access to a branch-specific field, add a second `SetLoadFields` covering those fields. The platform honors the in-branch call for the next record operation, so the extra data is fetched only when the branch runs. +Call `SetLoadFields` with the common fields. In each branch, call `AddLoadFields` with that branch's normal fields and then perform the record read. This applies only when the discriminator is known before the read; branching on a field from an already-loaded row is too late to tailor that row's initial SQL projection. See sample: `load-common-fields-before-branching-on-case.good.al`. ## Anti Pattern -A single top-level `SetLoadFields` enumerating every field any branch might read. On records whose state routes them to the fast common branch, the rarely-needed fields are still loaded — the optimization becomes a net-neutral or net-negative change on the hot path. +A single top-level `SetLoadFields` enumerating every branch's fields, or a branch-local `SetLoadFields` that accidentally discards the common selection. Both make the declared load plan differ from the fields the selected path actually uses. See sample: `load-common-fields-before-branching-on-case.bad.al`. diff --git a/microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.bad.al b/microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.bad.al deleted file mode 100644 index 66e59af..0000000 --- a/microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.bad.al +++ /dev/null @@ -1,24 +0,0 @@ -codeunit 50100 "Recent Orders Summary" -{ - procedure SummarizeRecentOrders(StartDate: Date; EndDate: Date) - var - SalesHeader: Record "Sales Header"; - begin - // "Document Type" and "Document Date" are listed in SetLoadFields even - // though they appear only in filters. Per-row values are transferred - // for columns the processing body never reads. - SalesHeader.SetLoadFields( - "Document Type", "Document Date", - "No.", "Sell-to Customer No.", "Amount Including VAT"); - - SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); - SalesHeader.SetRange("Document Date", StartDate, EndDate); - - if SalesHeader.FindSet() then - repeat - Emit(SalesHeader."No.", SalesHeader."Sell-to Customer No.", SalesHeader."Amount Including VAT"); - until SalesHeader.Next() = 0; - end; - - local procedure Emit(No: Code[20]; CustNo: Code[20]; Amount: Decimal) begin end; -} diff --git a/microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.good.al b/microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.good.al deleted file mode 100644 index 6e98764..0000000 --- a/microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.good.al +++ /dev/null @@ -1,22 +0,0 @@ -codeunit 50100 "Recent Orders Summary" -{ - procedure SummarizeRecentOrders(StartDate: Date; EndDate: Date) - var - SalesHeader: Record "Sales Header"; - begin - // "Document Type" and "Document Date" are used only in the filters below. - // The database index handles them; there is no need to load their values - // into AL memory for every row. - SalesHeader.SetLoadFields("No.", "Sell-to Customer No.", "Amount Including VAT"); - - SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); - SalesHeader.SetRange("Document Date", StartDate, EndDate); - - if SalesHeader.FindSet() then - repeat - Emit(SalesHeader."No.", SalesHeader."Sell-to Customer No.", SalesHeader."Amount Including VAT"); - until SalesHeader.Next() = 0; - end; - - local procedure Emit(No: Code[20]; CustNo: Code[20]; Amount: Decimal) begin end; -} diff --git a/microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.md b/microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.md deleted file mode 100644 index c475f1b..0000000 --- a/microsoft/knowledge/performance/omit-filter-only-fields-from-setloadfields.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [setloadfields, filter, field-exclusion, index] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Omit filter-only fields from SetLoadFields - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Fields used only in `SetRange` and `SetFilter` do their work at the database level using indexes; their values never need to be loaded into AL memory for the filter to apply. Listing such fields in `SetLoadFields` costs the transfer and memory footprint of every row's value for no functional benefit. Distinguishing filter-only fields from processing fields keeps the loaded column set as narrow as the iterating code actually reads. - -## Best Practice - -Include in `SetLoadFields` exactly the fields the iterating code reads. Fields referenced only in `SetRange`/`SetFilter` stay out of the list — filtering continues to work correctly because the database uses the index. Treat the audit as "what does the `repeat…until` block touch?" rather than "what does this procedure mention?". - -See sample: `omit-filter-only-fields-from-setloadfields.good.al`. - -## Anti Pattern - -Listing every field the procedure mentions in `SetLoadFields`, including date-range or status fields that appear only in filters. The loaded record now carries per-row values for columns the processing body never reads, inflating memory and network cost without changing any behavior. - -See sample: `omit-filter-only-fields-from-setloadfields.bad.al`. diff --git a/microsoft/knowledge/performance/order-case-branches-by-frequency.good.al b/microsoft/knowledge/performance/order-case-branches-by-frequency.good.al index c650375..707e696 100644 --- a/microsoft/knowledge/performance/order-case-branches-by-frequency.good.al +++ b/microsoft/knowledge/performance/order-case-branches-by-frequency.good.al @@ -2,8 +2,7 @@ codeunit 50100 "Document Router" { procedure Route(SalesHeader: Record "Sales Header") begin - // In this deployment Orders are ~85% of posting calls, Invoices ~12%, - // and the rest are edge cases. The hot branch goes first. + // Profiling shows Orders are the common case, so that branch goes first. case SalesHeader."Document Type" of SalesHeader."Document Type"::Order: RouteOrder(SalesHeader); @@ -11,15 +10,16 @@ codeunit 50100 "Document Router" RouteInvoice(SalesHeader); SalesHeader."Document Type"::"Credit Memo": RouteCreditMemo(SalesHeader); + SalesHeader."Document Type"::Quote: + RouteQuote(SalesHeader); SalesHeader."Document Type"::"Return Order": RouteReturnOrder(SalesHeader); - else - Error('Unexpected document type %1', SalesHeader."Document Type"); end; end; local procedure RouteOrder(SalesHeader: Record "Sales Header") begin end; local procedure RouteInvoice(SalesHeader: Record "Sales Header") begin end; + local procedure RouteQuote(SalesHeader: Record "Sales Header") begin end; local procedure RouteCreditMemo(SalesHeader: Record "Sales Header") begin end; local procedure RouteReturnOrder(SalesHeader: Record "Sales Header") begin end; } diff --git a/microsoft/knowledge/performance/order-case-branches-by-frequency.md b/microsoft/knowledge/performance/order-case-branches-by-frequency.md index 5768004..1634475 100644 --- a/microsoft/knowledge/performance/order-case-branches-by-frequency.md +++ b/microsoft/knowledge/performance/order-case-branches-by-frequency.md @@ -13,16 +13,16 @@ application-area: [all] ## Description -The AL `case` statement evaluates branches in the order they appear. When the distribution of the discriminator is heavily skewed — one or two values handle the vast majority of records, and the rest handle edge cases — the average cost of the statement is dominated by how many branches precede the common one. For evenly distributed discriminators the order does not matter; for skewed distributions it changes the hot-path cost of every call site. +AL documentation does not guarantee that a `case` statement uses a linear comparison strategy, so branch frequency alone is not proof of a performance issue. Reordering is justified only when profiling on the target runtime shows that a large, heavily skewed `case` is a material hot path. It is not a default review finding. ## Best Practice -Where the runtime frequency of values is known or measurable, list the common branches first. An `else` arm that handles unexpected values belongs last. When the common branch is also the simplest to evaluate, the placement compounds: the hot path is both short and cheap, and the uncommon branches are never touched on typical records. +After profiling confirms the comparison path matters and the runtime frequency is known, list common branches first without changing the set of handled values, fallback behavior, or branch bodies. See sample: `order-case-branches-by-frequency.good.al`. ## Anti Pattern -Ordering branches alphabetically, by enum declaration order, or by "logical grouping" when the runtime distribution is heavily skewed. Every common record pays the cost of evaluating every uncommon branch first; on a posting routine processing thousands of rows the overhead is measurable. +Reordering branches based on assumed frequency without profiling, or changing an `else` arm or handled value while making the optimization. The good and bad forms must differ only in branch order. See sample: `order-case-branches-by-frequency.bad.al`. 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 index 458d098..fea22d8 100644 --- a/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md +++ b/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md @@ -11,12 +11,12 @@ application-area: [all] ## 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. +An AL `Dictionary` directly models an unordered unique key-to-value collection. A temporary table models records and supports keys, filters, validation, and ordered iteration in Business Central Server memory. For a pure lookup map, the dictionary avoids repeatedly configuring and searching a temporary record and makes the intended access pattern explicit. ## 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. +Use `Dictionary of [Key, Value]` when the operation is add-or-replace, contains-key, and get-value by one supported key type. Use a temporary table when the value is a record, or when the code needs filters, ordered iteration, multiple fields, multiple keys, or table behavior. Both structures consume service-tier memory and still need volume analysis. ## 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. +A temporary record used only through `SetRange(KeyField, X); FindFirst()` to retrieve one scalar value, with no record semantics that justify the table. The opposite mistake is replacing a temporary table that needs filtering or ordered iteration with a dictionary. 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 index 0b8c21d..1626d02 100644 --- a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al @@ -1,15 +1,29 @@ +table 50243 "Perf Import Staging Entry" +{ + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Batch ID"; Guid) { } + field(3; Processed; Boolean) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} + codeunit 50243 "Perf Sample ModifyAll Bad" { - procedure ApplyPriceUpdate(NewPrice: Decimal) + procedure MarkBatchProcessed(BatchId: Guid) var - SalesLine: Record "Sales Line"; + StagingEntry: Record "Perf Import Staging Entry"; begin - SalesLine.SetRange(Type, SalesLine.Type::Item); - // N writes when one ModifyAll would do. - if SalesLine.FindSet() then + StagingEntry.SetRange("Batch ID", BatchId); + if StagingEntry.FindSet(true) then repeat - SalesLine.Validate("Unit Price", NewPrice); - SalesLine.Modify(true); - until SalesLine.Next() = 0; + StagingEntry.Processed := true; + StagingEntry.Modify(false); + until StagingEntry.Next() = 0; end; } diff --git a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al index c33d9c0..9a3ad4c 100644 --- a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al @@ -1,20 +1,26 @@ +table 50242 "Perf Import Staging Entry" +{ + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Batch ID"; Guid) { } + field(3; Processed; Boolean) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} + codeunit 50242 "Perf Sample ModifyAll Good" { - procedure ApplyPriceUpdate(NewPrice: Decimal) + procedure MarkBatchProcessed(BatchId: Guid) var - SalesLine: Record "Sales Line"; + StagingEntry: Record "Perf Import Staging Entry"; 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); - CustLedgerEntry.ModifyAll("Accepted Payment Tolerance", ToleranceAmount); - CustLedgerEntry.ModifyAll("Accepted Pmt. Disc. Tolerance", false); + StagingEntry.SetRange("Batch ID", BatchId); + // Processed has no OnValidate logic, and the equivalent loop uses Modify(false). + StagingEntry.ModifyAll(Processed, true, 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 index 73a095c..ae83968 100644 --- a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md @@ -7,20 +7,20 @@ countries: [w1] application-area: [all] --- -# Use ModifyAll / DeleteAll instead of per-row Modify / Delete in a loop +# Use ModifyAll only for equivalent bulk assignments ## 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. +`ModifyAll` assigns one value to one field across the filtered set. It does not run the field's `OnValidate` trigger. Its optional `RunTrigger` parameter controls the table `OnModify` trigger, not field validation. Replacing a loop is therefore correct only when direct assignment is semantically equivalent for every row. ## 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. +Use `ModifyAll` when the loop directly assigns the same value, does not call `Validate`, needs no per-row calculation, and does not depend on `OnModify` unless the equivalent `RunTrigger` value is supplied. Check whether table-extension triggers, event subscribers, global triggers, or media fields force row-by-row fallback (see `triggers-and-media-field-regress-modifyall.md`). 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. +A loop that only assigns a constant and calls `Modify(false)` on a field with no validation side effects. Conversely, replacing `Validate(Field, Value); Modify(true)` with `ModifyAll(Field, Value)` is also an anti-pattern because it silently drops field validation and may drop table-trigger behavior. 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 index c23886c..7827a1f 100644 --- a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al +++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al @@ -1,13 +1,13 @@ codeunit 50233 "Perf Sample ReadIso Bad" { - procedure GetOrCreate(var AgentStatus: Record "Agent Status") + procedure IsCustomerBlocked(CustomerNo: Code[20]): Boolean + var + Customer: Record Customer; 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; + Customer.LockTable(); + if not Customer.Get(CustomerNo) then + exit(false); + + exit(Customer.Blocked <> Customer.Blocked::" "); 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 index be5cd55..280c3d2 100644 --- a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al +++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al @@ -1,11 +1,13 @@ codeunit 50232 "Perf Sample ReadIso Good" { - procedure GetOrCreate(var AgentStatus: Record "Agent Status") + procedure IsCustomerBlocked(CustomerNo: Code[20]): Boolean + var + Customer: Record Customer; begin - AgentStatus.ReadIsolation := IsolationLevel::ReadCommitted; - if not AgentStatus.Get() then begin - AgentStatus.Init(); - AgentStatus.Insert(); - end; + Customer.ReadIsolation := IsolationLevel::ReadCommitted; + if not Customer.Get(CustomerNo) then + exit(false); + + exit(Customer.Blocked <> Customer.Blocked::" "); 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 index c2f888b..f798636 100644 --- a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md +++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md @@ -11,16 +11,16 @@ application-area: [all] ## 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. +Without read scale-out, `LockTable` causes subsequent reads of that table in the transaction to use `UPDLOCK`. With read scale-out, those reads use `REPEATABLEREAD` on the replica instead. `ReadIsolation` selects an isolation level for one record instance. A helper that only reads should not broaden locking for the table merely to request committed data. ## 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). +For a read-only operation that specifically requires committed data, set `Rec.ReadIsolation := IsolationLevel::ReadCommitted` immediately before the read. If the default isolation is sufficient, set neither property. `ReadCommitted` can still block behind writers and does not guarantee that repeated reads stay unchanged; use the isolation level required by the operation. Reserve update locks for read-before-write logic, not read-only helpers. 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. +`Rec.LockTable();` at the top of a helper that only reads, perhaps to "make sure the read is consistent". It takes stronger isolation than the helper needs and changes later reads of that table in the surrounding transaction or read-scale-out session. 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 deleted file mode 100644 index f290448..0000000 --- a/microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -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/singleton-setup-tables-need-no-access-optimization.md b/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md index 6b32de1..796c918 100644 --- a/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md +++ b/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md @@ -7,16 +7,16 @@ countries: [w1] application-area: [all] --- -# Singleton setup tables hold one row; access-pattern optimization is wasted +# Enforced singleton setup tables need no access optimization ## 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. +An access-pattern exemption is valid only for a table whose schema and write paths enforce at most one row for the relevant scope. A conventional blank primary key, a parameterless `Get()`, or a table name ending in `Setup` does not enforce that invariant; another primary-key value can still create another row unless insertion logic prevents it. ## 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`). +Exempt a setup read only after confirming that noncanonical keys are rejected and every supported creation path preserves the singleton. Otherwise apply ordinary access-pattern analysis, even when existing application code normally uses one blank-key record. ## 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. +Treating every `*Setup` table or parameterless `Get()` as proof of bounded cardinality without checking the primary key and insertion logic. diff --git a/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md index d799b5f..3fd8072 100644 --- a/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md +++ b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md @@ -7,16 +7,16 @@ countries: [w1] application-area: [all] --- -# Temporary tables are in-memory; access-pattern rules do not apply +# Temporary tables avoid SQL I/O, not in-memory work ## 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. +A temporary table stores its rows in Business Central Server memory instead of a physical SQL table. Its reads and writes therefore do not incur SQL round-trips, locking, or SIFT maintenance. They still allocate memory and execute record filtering, key lookup, sorting, insertion, and iteration in the service tier; those costs grow with the temporary dataset and access pattern. ## 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. +Do not apply SQL-specific findings such as missing `SetLoadFields`, lock contention, or N+1 database round-trips to a temporary record. Still assess memory volume and repeated scans or lookups. For a pure key-to-value collection, consider an AL `Dictionary`; keep a temporary table when record fields, keys, filtering, or ordered iteration are required. ## 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. +Claiming that every temporary-table access pattern is free because no SQL is involved. A nested scan over a large in-memory buffer can still dominate service-tier CPU, while adding `SetLoadFields` to that buffer addresses a database cost that does not exist. diff --git a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al index 9e016d1..d91d454 100644 --- a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al +++ b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.bad.al @@ -1,19 +1,29 @@ -codeunit 50100 "Stale Quote Cleanup" +table 50100 "Perf Import Buffer" { - procedure ClearExpiredQuotes(CutoffDate: Date) - var - SalesHeader: Record "Sales Header"; - begin - SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote); - SalesHeader.SetFilter("Document Date", '<%1', CutoffDate); - SalesHeader.SetRange(Status, SalesHeader.Status::Open); + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Batch ID"; Guid) { } + field(3; Payload; Blob) { } + } - // One SQL DELETE per row. On a 10k-row cleanup, minutes instead of - // under a second - and the OnDelete trigger has no logic this call - // needs to run. - if SalesHeader.FindSet() then + keys + { + key(PK; "Entry No.") { Clustered = true; } + key(ByBatch; "Batch ID") { } + } +} + +codeunit 50100 "Perf Import Buffer Cleanup" +{ + procedure ClearBatch(BatchId: Guid) + var + ImportBuffer: Record "Perf Import Buffer"; + begin + ImportBuffer.SetRange("Batch ID", BatchId); + if ImportBuffer.FindSet() then repeat - SalesHeader.Delete(); - until SalesHeader.Next() = 0; + ImportBuffer.Delete(false); + until ImportBuffer.Next() = 0; end; } diff --git a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al index 697edd9..738780c 100644 --- a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al +++ b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.good.al @@ -1,17 +1,28 @@ -codeunit 50100 "Stale Quote Cleanup" +table 50100 "Perf Import Buffer" { - procedure ClearExpiredQuotes(CutoffDate: Date) - var - SalesHeader: Record "Sales Header"; - begin - // OnDelete on Sales Header carries no logic this call depends on: - // expired quotes have no ledger entries, shipments, or downstream state. - SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote); - SalesHeader.SetFilter("Document Date", '<%1', CutoffDate); - SalesHeader.SetRange(Status, SalesHeader.Status::Open); + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Batch ID"; Guid) { } + field(3; Payload; Blob) { } + } - // Single SQL DELETE. Orders of magnitude faster than FindSet + Delete - // once the filtered set exceeds a handful of rows. - SalesHeader.DeleteAll(); + keys + { + key(PK; "Entry No.") { Clustered = true; } + key(ByBatch; "Batch ID") { } + } +} + +codeunit 50100 "Perf Import Buffer Cleanup" +{ + procedure ClearBatch(BatchId: Guid) + var + ImportBuffer: Record "Perf Import Buffer"; + begin + ImportBuffer.SetRange("Batch ID", BatchId); + // This staging table has no base delete trigger. Installed extensions and + // subscribers must also be checked before assuming the set-based fast path. + ImportBuffer.DeleteAll(false); end; } diff --git a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md index 0c5a1de..1c80835 100644 --- a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md +++ b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md @@ -13,16 +13,16 @@ application-area: [all] ## Description -`DeleteAll` translates to a single SQL `DELETE` with the record variable's current filters applied as the WHERE clause. A loop of `FindSet` + `Delete` instead issues one SQL statement per row. On any dataset larger than a handful of records, the gap is an order of magnitude or more. The tradeoff is that `DeleteAll` bypasses the `OnDelete` table trigger, so the decision hinges on whether that trigger's logic is required for this specific deletion. +`DeleteAll(false)` is eligible for a set-based SQL delete with the record variable's filters applied. It is not guaranteed to stay one statement. The base table `OnDelete` trigger is skipped, but table-extension `OnBeforeDelete` and `OnAfterDelete` triggers still run. Extension event subscribers, global delete triggers, and media fields can also require row processing. `DeleteAll(true)` runs the base table `OnDelete` trigger as well and has no performance advantage over `Delete(true)` in a loop. ## Best Practice -After narrowing the record set with `SetRange`/`SetFilter`, use `DeleteAll` whenever the `OnDelete` trigger has no logic that this call depends on — typically the case for housekeeping routines, staging-table cleanup, and deletions already validated upstream. When the trigger IS required, either keep the explicit loop-plus-`Delete` pattern and comment why, or pre-run the trigger logic against a temporary buffer and then `DeleteAll` the primary table. +Use filtered `DeleteAll(false)` for purpose-built staging or cleanup tables only after verifying that base-table `OnDelete` logic is unnecessary and installed extensions, subscribers, global triggers, and media fields do not add required per-row behavior or regress the bulk path. If deletion requires per-row business logic, keep an explicit triggered operation instead of simulating trigger execution separately. See sample: `use-deleteall-for-filtered-bulk-deletion.good.al`. ## Anti Pattern -Iterating with `FindSet` + `Delete` to clear a filtered set of records that carry no meaningful `OnDelete` logic. Every row pays a full AL round-trip; on a ten-thousand-row cleanup the loop can take minutes where `DeleteAll` takes under a second. +Iterating with `FindSet` + `Delete(false)` to clear a filtered staging batch that has no delete logic. The reverse mistake is assuming `DeleteAll` is always one SQL statement without checking table extensions and subscribers. See sample: `use-deleteall-for-filtered-bulk-deletion.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 index f19636a..abd6acb 100644 --- a/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md +++ b/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md @@ -7,16 +7,16 @@ countries: [w1] application-area: [all] --- -# Use TextBuilder for many string concatenations, especially inside loops +# Use AL TextBuilder for repeated text mutation ## 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. +AL `TextBuilder` is a reference type intended for modifying text without creating a new `Text` value for each change. Microsoft documents it as the performance-oriented AL primitive for concatenating many strings, including loop-built output. `Append` and `AppendLine` build the value, and `ToText` returns the completed text. ## 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. +When a loop repeatedly appends fragments to one result, use a `TextBuilder` local and convert once after the loop. Keep ordinary `Text` expressions for a fixed, small number of fragments; this rule is about repeated mutation, not every concatenation. ## 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. +Building an unbounded export or message with `Result += Fragment` on every iteration when AL's `TextBuilder` directly represents the operation. From 9214f73819ddcec6f46a6476509f8faf439547ed Mon Sep 17 00:00:00 2001 From: Wenjie Fan <31087545+gggdttt@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:26:43 +0200 Subject: [PATCH 17/86] style-review: calibrate analyzer-redundant rules to info; keep correctness bugs out of style scope (#95) Online-eval data shows the style leaf is the largest source of dismissed findings. Two causes: - Mechanical, analyzer-enforced conventions (this-keyword AA0248, label suffixes AA0074, missing ToolTip, label scope) fire at gating severity and duplicate what CodeCop/AppSourceCop already report. Calibrate them to info so a severity-gating consumer drops the redundant noise. - Correctness/logic/data-integrity defects get reframed as style conventions and emitted here. Sharpen the scope boundary: such defects belong to the relevant domain leaf, or to al-code-review's cross-cutting agent channel when no knowledge file covers them, never to this leaf. Co-authored-by: wenjiefan --- microsoft/skills/review/al-style-review.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index fb15dba..4c00c45 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -53,13 +53,15 @@ When the post-conflict worklist is empty because no applicable style knowledge e 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`. +Severity calibration — a formal analyzer already flags the mechanical presence/naming conventions (the `this` keyword AA0248, approved label suffixes AA0074, a missing `ToolTip`, a `Label` declared at local instead of object scope, required parentheses). On those, BCQuality's value is the *explanation* of why the rule exists, not a second gate; emit them at `info` so a consumer that gates on severity does not re-flag what CodeCop/AppSourceCop already reports. Reserve `minor` for style issues with concrete downstream impact the analyzer does not catch — lost translation or telemetry classification from a string-built `Error`, an `OptionCaption` that does not match its `OptionMembers`, a misleading named invocation. This keeps the domain's default output advisory and prevents analyzer-redundant noise from competing with substantive review. + 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. -After evaluating each worklist entry, also consider whether the diff exhibits a style defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a clear, widely-accepted AL style violation with a concrete basis a knowledgeable BC reviewer would agree on — steelman it first and drop personal preference, speculation, and any single defensible formatting choice among several; when in doubt, omit. The scope is strictly style; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. +After evaluating each worklist entry, also consider whether the diff exhibits a style defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a clear, widely-accepted AL style violation with a concrete basis a knowledgeable BC reviewer would agree on — steelman it first and drop personal preference, speculation, and any single defensible formatting choice among several; when in doubt, omit. The scope is strictly style — naming, labelling, formatting, and analyzer-adjacent conventions. A correctness, logic, data-integrity, or contract defect is NOT a style finding even when it can be reworded as a convention: a method that mutates a shared `Record`'s filters, an unfiltered `DeleteAll`, a violated interface contract, or a wrong boolean guard are behavioural defects, not conventions — do not emit them here under a style framing. If a specific domain leaf covers the concern (performance, security, error-handling, …) it belongs there; if no knowledge file in any domain covers it, it belongs to the `al-code-review` super-skill's cross-cutting self-review agent channel (`from-sub-skill: "agent"`, `severity` capped at `minor`), not to this leaf. A reliable test: if you cannot cite a style `## Best Practice`/`## Anti Pattern` for the concern, it is very likely not a style finding. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. @@ -82,13 +84,13 @@ Output conforms to the DO output contract. A populated example: "skill": { "id": "al-style-review", "version": 1 }, "outcome": "completed", "summary": { - "counts": { "blocker": 0, "major": 0, "minor": 1, "info": 0 }, + "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 1 }, "coverage": { "worklist-size": 1, "items-evaluated": 1 } }, "findings": [ { - "id": "microsoft/knowledge/style/label-suffix-approved-list.md", - "severity": "minor", + "id": "microsoft/knowledge/style/apply-approved-label-suffixes.md", + "severity": "info", "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", From 98af9aa1fc10bb7df78c2bcc012e1a4803877c91 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 14 Jul 2026 12:50:30 +0200 Subject: [PATCH 18/86] Add missing AL review leaf skills (#96) * Add missing AL review leaves Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 62d512a7-fd54-43dc-8eb5-485b909c72e5 * Refine test isolation review cue Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 62d512a7-fd54-43dc-8eb5-485b909c72e5 --------- Co-authored-by: Jesper Schulz-Wedde --- .../skills/review/al-appsource-review.md | 128 +++++++++++++++++ microsoft/skills/review/al-code-review.md | 6 +- .../skills/review/al-data-modeling-review.md | 131 ++++++++++++++++++ .../skills/review/al-telemetry-review.md | 127 +++++++++++++++++ microsoft/skills/review/al-testing-review.md | 130 +++++++++++++++++ 5 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 microsoft/skills/review/al-appsource-review.md create mode 100644 microsoft/skills/review/al-data-modeling-review.md create mode 100644 microsoft/skills/review/al-telemetry-review.md create mode 100644 microsoft/skills/review/al-testing-review.md diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md new file mode 100644 index 0000000..b5490fe --- /dev/null +++ b/microsoft/skills/review/al-appsource-review.md @@ -0,0 +1,128 @@ +--- +kind: action-skill +id: al-appsource-review +version: 1 +title: AL AppSource review +description: Performs an AL AppSource review against source and app metadata guidance from BCQuality. +inputs: [pr-diff, file-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL AppSource review + +Reviews AL source and app metadata changes against the `appsource` 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). AppSource findings are narrow by design — they apply when the diff touches AppSourceCop configuration, AL object or extension-member names, or AppSource-facing `app.json` metadata. The skill returns `not-applicable` when none of those apply. + +## Source + +Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `appsource` as this skill's candidate set across every enabled Microsoft, community, and custom layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/appsource/**`. + +## 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 files and AL object types — especially `app.json`, `AppSourceCop.json`, new objects, and table/page/report extensions that add fields, keys, controls, or actions to base objects. +- The changed object and member names, weighted toward prefix/suffix consistency with `mandatoryAffixes` or `mandatoryPrefix`, plus AppSource-facing help metadata. +- Tokens extracted from the diff that relate to AppSource (`AppSourceCop`, `mandatoryAffixes`, `mandatoryPrefix`, `AS0011`, `prefix`, `suffix`, `tableextension`, `pageextension`, `reportextension`, `field`, `key`, `control`, `action`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`). + +A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no AppSource-related source or metadata changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. + +The following targeted checks cover every current `appsource` article across the Microsoft and community layers. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action. + +- A new or renamed object lacks the reserved prefix/suffix, or a tableextension/pageextension/reportextension adds an unaffixed field, key, control, or action to a base object despite `mandatoryAffixes`/`mandatoryPrefix` and AS0011 — `object-affixes-prevent-collisions`. +- For BC v24 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent — `keep-copilot-help-url-to-two-path-levels`. + +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 AppSource knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable AppSource 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 change violates an AppSource submission requirement; otherwise 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. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match (affix configuration/name or URL path depth). +- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +After evaluating each worklist entry, also consider whether the diff exhibits an AppSource defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material AppSource defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly AppSource; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. + +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add the configured affix to one object or extension member, or replace a deep help URL with a known two-level canonical URL). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. + +Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item. +- `no-knowledge` — no applicable AppSource knowledge survived filtering. +- `not-applicable` — the diff touches no AppSource source, analyzer configuration, or app-metadata 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-appsource-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/appsource/object-affixes-prevent-collisions.md", + "severity": "major", + "message": "The tableextension adds an unaffixed Loyalty Points field to Customer, so it violates the configured AppSource affix and can collide with another extension.", + "location": { + "file": "src/CustomerExt.TableExt.al", + "line": 8 + }, + "references": [ + { "path": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md" } + ], + "confidence": "high", + "suggested-code": "field(50100; \"Loyalty Points ABC\"; Integer)" + } + ], + "suppressed": [] +} +``` + +The empty-corpus case produces: + +```json +{ + "skill": { "id": "al-appsource-review", "version": 1 }, + "outcome": "no-knowledge", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 0, "items-evaluated": 0 } + }, + "findings": [], + "suppressed": [] +} +``` diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 3ea0356..2e68e36 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -22,6 +22,10 @@ sub-skills: - microsoft/skills/review/al-interfaces-review.md - microsoft/skills/review/al-breaking-changes-review.md - microsoft/skills/review/al-web-services-review.md + - microsoft/skills/review/al-testing-review.md + - microsoft/skills/review/al-data-modeling-review.md + - microsoft/skills/review/al-appsource-review.md + - microsoft/skills/review/al-telemetry-review.md --- # AL code review @@ -34,7 +38,7 @@ An orchestrator invokes this skill with either a `pr-diff` (the standard PR-revi ## Source -The sub-skills invoked by this skill are those listed in frontmatter `sub-skills`. Additional leaf skills (for example, telemetry, testing) are added by updating the `sub-skills` list. The skill does not discover sub-skills implicitly. +The sub-skills invoked by this skill are those listed in frontmatter `sub-skills`. Additional leaf skills are added by updating the `sub-skills` list. The skill does not discover sub-skills implicitly. ## Relevance diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md new file mode 100644 index 0000000..7cbe48d --- /dev/null +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -0,0 +1,131 @@ +--- +kind: action-skill +id: al-data-modeling-review +version: 1 +title: AL data-modeling review +description: Performs an AL data-modeling review against guidance from BCQuality. +inputs: [pr-diff, file-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL data-modeling review + +Reviews AL source changes against the `data-modeling` 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). Data-modeling findings are narrow by design — they apply when the diff touches setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, or audit fields. The skill returns `not-applicable` when none of those apply. + +## Source + +Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `data-modeling` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/data-modeling/**`. + +## 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 `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, and document or journal lines that reference a master. +- The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, and posting validation. +- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`). + +A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. + +The following targeted checks cover every current `data-modeling` article. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action. + +- A `* Setup` table or its page changes singleton structure, uses a nonblank or generated key, permits insert/delete, uses a List page, or does not ensure the blank-keyed row exists — `setup-table-is-a-singleton`. +- A custom master table changes its primary key, `No.`/`No. Series` fields, or `OnInsert` without assigning a blank `No.` from setup through a number series — `master-table-no-from-number-series-in-oninsert`. +- BC v22 or later code introduces or retains `NoSeriesManagement`, `InitSeries`, `SelectSeries`, or `SetSeries`, or number assignment/manual-entry checks do not use codeunit `"No. Series"` methods such as `GetNextNo`, `IsManual`, or `TestManual` — `use-no-series-codeunit-not-noseriesmanagement`. +- A master gains or changes `Blocked`, or a document line, journal line, reference-field `OnValidate`, or posting routine uses that master without `TestField(Blocked, false)` at the point of use; also cue when the check is placed only in the master's own triggers — `check-blocked-in-referencing-code-not-in-master`. +- A master table adds or changes `Last Date Modified`, `OnModify`, or `OnRename`, but the non-editable field is not assigned `Today()` in both triggers — `set-last-date-modified-in-onmodify-and-onrename`. + +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 data-modeling knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable data-modeling 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 model can create ambiguous setup state, incompatible business identifiers, or silently stale synchronization data; otherwise 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. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match (object type, field, key, trigger, or API name). +- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +After evaluating each worklist entry, also consider whether the diff exhibits a data-modeling defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material data-modeling defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly data modeling; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. + +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add `InsertAllowed = false` or `DeleteAllowed = false`; replace `WorkDate()` with `Today()`; add the same audit-field assignment to `OnRename`; or replace an obsolete number-series codeunit declaration). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. + +Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item. +- `no-knowledge` — no applicable data-modeling knowledge survived filtering. +- `not-applicable` — the diff touches no setup/master table, page, key, numbering, block-check, or audit-field 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-data-modeling-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/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md", + "severity": "major", + "message": "The table updates Last Date Modified in OnModify but not OnRename, so renaming the primary key leaves the audit date stale and can hide the record from incremental integrations.", + "location": { + "file": "src/LoyaltyMember.Table.al", + "line": 74 + }, + "references": [ + { "path": "microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md" } + ], + "confidence": "high", + "suggested-code": "trigger OnRename()\nbegin\n \"Last Date Modified\" := Today();\nend;" + } + ], + "suppressed": [] +} +``` + +The empty-corpus case produces: + +```json +{ + "skill": { "id": "al-data-modeling-review", "version": 1 }, + "outcome": "no-knowledge", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 0, "items-evaluated": 0 } + }, + "findings": [], + "suppressed": [] +} +``` diff --git a/microsoft/skills/review/al-telemetry-review.md b/microsoft/skills/review/al-telemetry-review.md new file mode 100644 index 0000000..3c0a723 --- /dev/null +++ b/microsoft/skills/review/al-telemetry-review.md @@ -0,0 +1,127 @@ +--- +kind: action-skill +id: al-telemetry-review +version: 1 +title: AL telemetry review +description: Performs an AL telemetry review against guidance from BCQuality. +inputs: [pr-diff, file-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL telemetry review + +Reviews AL source changes against the `telemetry` 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). Telemetry findings are narrow by design — they apply when the diff emits, wraps, or changes custom telemetry through `Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, or related telemetry helpers. The skill returns `not-applicable` when none of those apply. + +## Source + +Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `telemetry` as this skill's candidate set across every enabled Microsoft, community, and custom layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/telemetry/**`. + +## 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 objects and procedures — especially telemetry wrapper codeunits, feature lifecycle instrumentation, error logging, integration diagnostics, and background/session processing. +- Calls to `Session.LogMessage`, `Session.LogError`, or `FeatureTelemetry` methods, weighted toward the event ID, verbosity, data classification, custom dimensions, and `TelemetryScope` arguments. +- Tokens extracted from the diff that relate to telemetry (`Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, `TelemetryScope`, `ExtensionPublisher`, `All`, `Verbosity`, `DataClassification`, `CustomDimensions`, `Application Insights`, `LogUsage`, `LogError`, `LogUptake`, `Feature Uptake Status`). + +A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no telemetry-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. + +The following targeted check covers every current `telemetry` article across the Microsoft and community layers. Treat it as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action. + +- `Session.LogMessage` or `Session.LogError` uses `TelemetryScope::All` for publisher-only diagnostics, a telemetry wrapper defaults its scope to `All`, or a `FeatureTelemetry`/custom logging change routes signals to customer environment telemetry without a customer-actionable reason — `default-telemetryscope-to-extensionpublisher`. + +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 telemetry knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable telemetry 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; otherwise 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. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous API and `TelemetryScope` argument. +- `medium` when determining whether a signal is customer-actionable requires heuristic interpretation or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +After evaluating each worklist entry, also consider whether the diff exhibits a telemetry defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material telemetry defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly telemetry; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. + +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: replace `TelemetryScope::All` with `TelemetryScope::ExtensionPublisher` for a clearly publisher-only diagnostic). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. + +Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item. +- `no-knowledge` — no applicable telemetry knowledge survived filtering. +- `not-applicable` — the diff touches no telemetry emission, wrapper, or feature-instrumentation 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-telemetry-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 0, "major": 1, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 1, "items-evaluated": 1 } + }, + "findings": [ + { + "id": "community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.md", + "severity": "major", + "message": "This publisher-only diagnostic uses TelemetryScope::All, which also sends it to each customer's environment telemetry and adds avoidable ingestion cost.", + "location": { + "file": "src/Telemetry/Diagnostics.Codeunit.al", + "line": 31 + }, + "references": [ + { "path": "community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.md" } + ], + "confidence": "high", + "suggested-code": "TelemetryScope::ExtensionPublisher" + } + ], + "suppressed": [] +} +``` + +The empty-corpus case produces: + +```json +{ + "skill": { "id": "al-telemetry-review", "version": 1 }, + "outcome": "no-knowledge", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 0, "items-evaluated": 0 } + }, + "findings": [], + "suppressed": [] +} +``` diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md new file mode 100644 index 0000000..54aeded --- /dev/null +++ b/microsoft/skills/review/al-testing-review.md @@ -0,0 +1,130 @@ +--- +kind: action-skill +id: al-testing-review +version: 1 +title: AL testing review +description: Performs an AL testing review against guidance from BCQuality. +inputs: [pr-diff, file-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL testing review + +Reviews AL source changes against the `testing` 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). Testing findings are narrow by design — they apply when the diff touches test codeunits, test runners, test methods, handlers, assertions, or fixture construction. The skill returns `not-applicable` when none of those apply. + +## Source + +Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `testing` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/testing/**`. + +## 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 = Test`, test runner codeunits with `TestIsolation`, test libraries, and codeunits that define UI handlers. +- The changed methods and attributes, weighted toward `[Test]`, `[TransactionModel(...)]`, `[HandlerFunctions(...)]`, handler attributes, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, fixture initialization, and test-library calls. +- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `TestIsolation`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Init`, `Insert`). + +A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no testing-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. + +The following targeted checks cover every current `testing` article. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action. + +- A method in a `Subtype = Test` codeunit adds or changes `[TransactionModel(...)]`, exercises code that calls `Commit`, defaults broadly to `AutoCommit`, or uses `AutoCommit` (or exercises a path that calls `Commit`) without a `TestIsolation`-enabled runner — `transactionmodel-attribute-governs-test-transactions`. Do not worklist this article solely because an ordinary `AutoRollback` or read-only test has no `TestIsolation` runner. +- Test fixture code manually calls `Init`/`Insert`, invents keys or prerequisite records, or bypasses available `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, or equivalent library codeunits — `use-library-codeunits-for-test-fixtures`. +- `asserterror` is added or changed without a following `Assert.ExpectedError`, `Assert.ExpectedErrorCode`, or a purpose-built assertion such as `ExpectedTestFieldError` — `asserterror-needs-expectederror-and-code`. +- A test path raises UI, `[HandlerFunctions(...)]` does not exactly match the invoked handlers, a handler hardcodes replies instead of using enqueue/dequeue expectations, or `LibraryVariableStorage.Clear`/`AssertEmpty` is missing — `ui-handlers-in-tests`. + +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 testing knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable testing 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 test can pass while verifying the wrong behavior or can leave committed data that contaminates later tests; otherwise 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. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match (attribute, handler declaration, assertion sequence, or fixture call). +- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +After evaluating each worklist entry, also consider whether the diff exhibits a testing defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material testing defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly AL testing; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. + +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add the matching `ExpectedError` assertion after `asserterror`; add or remove a handler name in `HandlerFunctions`; add `LibraryVariableStorage.Clear` or `AssertEmpty`; or replace hand-rolled fixture creation with an evident library call). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. + +Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item. +- `no-knowledge` — no applicable testing knowledge survived filtering. +- `not-applicable` — the diff touches no test codeunit, runner, method, handler, assertion, or fixture 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-testing-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/testing/asserterror-needs-expectederror-and-code.md", + "severity": "major", + "message": "The negative test uses asserterror without checking the resulting message or error code, so any unrelated setup or permission error can make the test pass.", + "location": { + "file": "test/SalesPostingTests.Codeunit.al", + "line": 42 + }, + "references": [ + { "path": "microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md" } + ], + "confidence": "high", + "suggested-code": "asserterror PostInvalidOrder();\nAssert.ExpectedError(ExpectedPostingErr);" + } + ], + "suppressed": [] +} +``` + +The empty-corpus case produces: + +```json +{ + "skill": { "id": "al-testing-review", "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 078b869e3303ccaa59e9836301fe8dbec68c614d Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 14 Jul 2026 12:51:39 +0200 Subject: [PATCH 19/86] Add per-row AL performance guidance (#97) * Add per-row performance guidance Document SetAutoCalcFields for per-row FlowFields and direct writes on iterated records, with focused reviewer retrieval cues. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 85be3fc4-5253-47b8-ba1b-6b8fd188fcea * Bound commit checkpoints by key range Use a capped ordered query to discover each checkpoint watermark before locking and processing only that key range. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 85be3fc4-5253-47b8-ba1b-6b8fd188fcea * Address performance retrieval review Retrieve Commit-in-loop guidance precisely, process exact checkpoint key lists, and narrow clone-before-write discovery. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 85be3fc4-5253-47b8-ba1b-6b8fd188fcea --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...cords-before-modify-delete-in-loops.bad.al | 19 +++++++ ...ords-before-modify-delete-in-loops.good.al | 17 ++++++ ...g-records-before-modify-delete-in-loops.md | 26 ++++++++++ .../avoid-commit-inside-loops.good.al | 52 ++++++++++++++----- .../performance/avoid-commit-inside-loops.md | 8 +-- ...tocalcfields-for-per-row-flowfields.bad.al | 16 ++++++ ...ocalcfields-for-per-row-flowfields.good.al | 16 ++++++ ...etautocalcfields-for-per-row-flowfields.md | 26 ++++++++++ .../skills/review/al-performance-review.md | 12 ++++- 9 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.bad.al create mode 100644 microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.good.al create mode 100644 microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md create mode 100644 microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.bad.al create mode 100644 microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.good.al create mode 100644 microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md diff --git a/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.bad.al b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.bad.al new file mode 100644 index 0000000..0a70e85 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.bad.al @@ -0,0 +1,19 @@ +codeunit 50493 "Perf Record Clone Bad" +{ + procedure IncreaseCustomerCreditLimits(Percent: Decimal) + var + Customer: Record Customer; + CustomerCopy: Record Customer; + begin + Customer.SetLoadFields("Credit Limit (LCY)"); + Customer.SetFilter("Credit Limit (LCY)", '>0'); + if Customer.FindSet(true) then + repeat + CustomerCopy.Copy(Customer); + CustomerCopy.Validate( + "Credit Limit (LCY)", + Round(CustomerCopy."Credit Limit (LCY)" * (1 + Percent / 100))); + CustomerCopy.Modify(true); + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.good.al b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.good.al new file mode 100644 index 0000000..84bfda4 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.good.al @@ -0,0 +1,17 @@ +codeunit 50492 "Perf Record Clone Good" +{ + procedure IncreaseCustomerCreditLimits(Percent: Decimal) + var + Customer: Record Customer; + begin + Customer.SetLoadFields("Credit Limit (LCY)"); + Customer.SetFilter("Credit Limit (LCY)", '>0'); + if Customer.FindSet(true) then + repeat + Customer.Validate( + "Credit Limit (LCY)", + Round(Customer."Credit Limit (LCY)" * (1 + Percent / 100))); + Customer.Modify(true); + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md new file mode 100644 index 0000000..66ee684 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [clone, clone-before-write, copy, gettable, by-value, copied-record, writing-helper] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid cloning records before Modify or Delete in loops + +## Description + +Microsoft's [AL database-method performance guidance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-al-database-methods-and-performance-on-server#insert-modify-delete-and-locktable) states that cloning an iterated record before `Modify` or `Delete` restarts the SQL `SELECT` and issues an extra SQL statement for every row. The runtime treats `Record.Copy`, `RecordRef.GetTable`, and passing a record by value to a writing helper as clones in this situation. + +## Best Practice + +Use `FindSet(true)` when the loop writes the traversed rows, and call `Modify` or `Delete` on that iterating record variable. If generic code is required, open and iterate the `RecordRef` directly instead of calling `GetTable` for each typed record. Keep a per-row loop when validation or row-specific behavior is required; this rule does not imply that `ModifyAll` or `DeleteAll` is equivalent. + +See sample: `avoid-cloning-records-before-modify-delete-in-loops.good.al`. + +## Anti Pattern + +Inside an active traversal, copy the current row, convert it with `RecordRef.GetTable`, or pass it without `var` to a helper, then call `Modify` or `Delete` on that clone. Do not flag read-only snapshots, temporary records, or copies used to write a different target table; the documented extra-statement concern is clone-before-write on the traversed table. + +See sample: `avoid-cloning-records-before-modify-delete-in-loops.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al b/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al index e8b55b2..2ffa386 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al @@ -1,3 +1,17 @@ +query 50127 "Perf Customer Chunk" +{ + QueryType = Normal; + OrderBy = ascending(CustomerNo); + + elements + { + dataitem(Customer; Customer) + { + column(CustomerNo; "No.") { } + } + } +} + codeunit 50128 "Perf Sample CommitInLoop Good" { procedure NormalizeCustomerNames() @@ -5,28 +19,42 @@ codeunit 50128 "Perf Sample CommitInLoop Good" LastCustomerNo: Code[20]; begin // The outer loop owns checkpoints; the per-row loop contains no Commit. - while NormalizeNextChunk(LastCustomerNo, 500) do + while NormalizeNextChunk(LastCustomerNo) do Commit(); end; - local procedure NormalizeNextChunk(var LastCustomerNo: Code[20]; ChunkSize: Integer): Boolean + local procedure NormalizeNextChunk(var LastCustomerNo: Code[20]): Boolean var Customer: Record Customer; - RowsInChunk: Integer; + TempCustomer: Record Customer temporary; + CustomerChunk: Query "Perf Customer Chunk"; + LastChunkCustomerNo: Code[20]; begin - Customer.SetCurrentKey("No."); + CustomerChunk.TopNumberOfRows(500); if LastCustomerNo <> '' then - Customer.SetFilter("No.", '>%1', LastCustomerNo); - if not Customer.FindSet(true) then + CustomerChunk.SetFilter(CustomerNo, '>%1', LastCustomerNo); + CustomerChunk.Open(); + while CustomerChunk.Read() do begin + TempCustomer.Init(); + TempCustomer."No." := CustomerChunk.CustomerNo; + TempCustomer.Insert(); + LastChunkCustomerNo := CustomerChunk.CustomerNo; + end; + CustomerChunk.Close(); + + if TempCustomer.IsEmpty() then exit(false); - repeat - Customer.Name := UpperCase(Customer.Name); - Customer.Modify(); - LastCustomerNo := Customer."No."; - RowsInChunk += 1; - until (RowsInChunk >= ChunkSize) or (Customer.Next() = 0); + Customer.LockTable(); + if TempCustomer.FindSet() then + repeat + if Customer.Get(TempCustomer."No.") then begin + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); + end; + until TempCustomer.Next() = 0; + LastCustomerNo := LastChunkCustomerNo; exit(true); end; } diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.md b/microsoft/knowledge/performance/avoid-commit-inside-loops.md index 4dd5b11..13f483a 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, checkpoint, codeunit-run] +keywords: [commit, commit-in-loop, per-row-commit, checkpoint, bounded-checkpoint, watermark, topnumberofrows] technologies: [al] countries: [w1] application-area: [all] @@ -13,16 +13,16 @@ application-area: [all] ## Description -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. +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 select an exact list of at most N keys and process only those rows. ## Best Practice -If the batch is large enough that a single transaction is untenable, use an outer loop that selects and finishes the next N rows. Commit only after the inner row loop has returned and the checkpoint state identifies where the next chunk starts. A `Codeunit.Run` boundary can also own a chunk when its implicit commit and error behavior fit the caller — see `codeunit-run-as-atomic-sub-operation.md`. +If the batch is large enough that a single transaction is untenable, use an ordered primary-key watermark and retrieve a bounded next-N key list. `FindSet` is optimized for reading the complete filtered set and isn't implemented as `TOP X`, so calling it over the remaining tail and breaking after N rows does not bound retrieval. The sample uses a query capped by [`TopNumberOfRows`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/query/queryinstance-topnumberofrows-method) to fill a temporary key buffer, then takes update locks and modifies only those exact keys. It does not reconstruct an inclusive first-to-last range that concurrent inserts could expand. Commit after the bounded inner loop returns and persist its last selected key as the next watermark. Use a stable key and define how a later run handles records inserted at or below an already committed watermark. A `Codeunit.Run` boundary can also own a chunk when its implicit commit and error behavior fit the caller — see `codeunit-run-as-atomic-sub-operation.md`. See sample: `avoid-commit-inside-loops.good.al`. ## Anti Pattern -Placing Commit inside `repeat ... until Next() = 0` is almost always a mistake: it is unusual for the correctness of the operation to depend on per-row commits, and the cost of starting a new transaction on every row dominates the work. +Placing Commit inside `repeat ... until Next() = 0` is almost always a mistake: it is unusual for the correctness of the operation to depend on per-row commits, and the cost of starting a new transaction on every row dominates the work. A capped query that discovers only an upper key and then re-reads an inclusive key range is not exact batching either; concurrent inserts inside that range can enlarge the checkpoint. See sample: `avoid-commit-inside-loops.bad.al`. diff --git a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.bad.al b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.bad.al new file mode 100644 index 0000000..86b1842 --- /dev/null +++ b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.bad.al @@ -0,0 +1,16 @@ +codeunit 50491 "Perf AutoCalcFields Bad" +{ + procedure CollectOverLimitCustomers(var CustomerNos: List of [Code[20]]) + var + Customer: Record Customer; + begin + Customer.SetLoadFields("Credit Limit (LCY)"); + Customer.SetFilter("Credit Limit (LCY)", '>0'); + if Customer.FindSet() then + repeat + Customer.CalcFields("Balance (LCY)"); + if Customer."Balance (LCY)" > Customer."Credit Limit (LCY)" then + CustomerNos.Add(Customer."No."); + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.good.al b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.good.al new file mode 100644 index 0000000..072321c --- /dev/null +++ b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.good.al @@ -0,0 +1,16 @@ +codeunit 50490 "Perf AutoCalcFields Good" +{ + procedure CollectOverLimitCustomers(var CustomerNos: List of [Code[20]]) + var + Customer: Record Customer; + begin + Customer.SetLoadFields("Credit Limit (LCY)"); + Customer.SetFilter("Credit Limit (LCY)", '>0'); + Customer.SetAutoCalcFields("Balance (LCY)"); + if Customer.FindSet() then + repeat + if Customer."Balance (LCY)" > Customer."Credit Limit (LCY)" then + CustomerNos.Add(Customer."No."); + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md new file mode 100644 index 0000000..0c749ce --- /dev/null +++ b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [setautocalcfields, calcfields, calcsums, flowfield, loop, per-row] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use SetAutoCalcFields when each iterated row needs a FlowField + +## Description + +`Record.SetAutoCalcFields` has been available since runtime 1.0 and makes the specified FlowFields calculate as records are retrieved. Microsoft's [AL database-method performance guidance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-al-database-methods-and-performance-on-server#setautocalcfields) uses it to remove an explicit `CalcFields` call from every iteration when each row's FlowField drives a branch. This is different from `CalcSums`, which returns a total for the filtered set rather than a value for each row. + +## Best Practice + +Call `SetAutoCalcFields` before `FindSet` when every returned row needs the same FlowField for a comparison, branch, or per-record action. Use `CalcSums` instead when the required result is one aggregate over the filtered set (see `calcsums-instead-of-calcfields-in-loop.md`). + +See sample: `use-setautocalcfields-for-per-row-flowfields.good.al`. + +## Anti Pattern + +Calling `CalcFields` inside the loop when every iteration reads the same FlowField. Each `CalcFields` request requires a separate SQL statement unless a compatible recent result is cached. Do not replace row-specific decisions with `CalcSums`; an aggregate cannot preserve which rows met the condition. + +See sample: `use-setautocalcfields-for-per-row-flowfields.bad.al`. diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index 5b92d2b..a72d8ef 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -38,11 +38,19 @@ 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 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 and hot-path costs (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `CalcSums`). +- The changed procedures and triggers, weighted toward those that perform loops, Find/FindSet/FindFirst calls, CalcFields, SetAutoCalcFields, CalcSums, FlowField access, Commit calls, checkpoint helpers, record copying, RecordRef conversion, Modify/Delete calls, or cross-table navigation. +- Tokens extracted from the diff that relate to data access and hot-path costs (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. +Apply these targeted cues even when simple token overlap would rank the article below the worklist cutoff: + +- Worklist `use-setautocalcfields-for-per-row-flowfields.md` when a record loop calls `CalcFields`, or when every row reads the same FlowField for a comparison, branch, or per-record action. Worklist `calcsums-instead-of-calcfields-in-loop.md` instead when the loop only accumulates one set total. +- Worklist `avoid-commit-inside-loops.md` only when `Commit()` is inside a record-iteration body or a helper invoked once per row. Do not match one `Commit()` after a bounded checkpoint helper returns, a `Commit()` outside iteration, or comments and documentation that merely mention commits. +- Worklist `avoid-cloning-records-before-modify-delete-in-loops.md` when an iteration calls `Copy` or `RecordRef.GetTable` before `Modify`/`Delete`, or passes the iterated record without `var` to a helper that writes that record. Do not worklist it from `Modify`, `Delete`, or `RecordRef` alone; exclude a direct write on the iterator, a read-only copy, a temporary record, a different target table, and a `RecordRef` opened and iterated directly. + +These targeted inclusions and exclusions override generic token overlap. Do not retain an excluded article solely because the diff contains one of its keywords. + 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 performance knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable performance knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. From 363f08f47edd1ff2a0919dff383ef154f876c2ef Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 14 Jul 2026 12:51:59 +0200 Subject: [PATCH 20/86] Add P0 event and interface compatibility knowledge (#98) * Add P0 extensibility compatibility knowledge Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 645349fd-1892-48f3-8a84-db77d6abd1c3 * Correct event compatibility guidance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 645349fd-1892-48f3-8a84-db77d6abd1c3 --------- Co-authored-by: Jesper Schulz-Wedde --- ...add-new-event-parameters-at-the-end.bad.al | 17 +++------ ...dd-new-event-parameters-at-the-end.good.al | 19 +++++++--- .../add-new-event-parameters-at-the-end.md | 10 +++--- ...hange-shipped-event-attribute-flags.bad.al | 14 ++++++++ ...ange-shipped-event-attribute-flags.good.al | 21 +++++++++++ ...ot-change-shipped-event-attribute-flags.md | 26 ++++++++++++++ ...s-over-includesender-in-codeunit-events.md | 8 ++--- ...rnal-events-as-subscriber-contracts.bad.al | 15 ++++++++ ...nal-events-as-subscriber-contracts.good.al | 24 +++++++++++++ ...internal-events-as-subscriber-contracts.md | 26 ++++++++++++++ ...published-interfaces-dont-edit-them.bad.al | 16 +++++++++ ...ublished-interfaces-dont-edit-them.good.al | 23 ++++++++++++ ...end-published-interfaces-dont-edit-them.md | 26 ++++++++++++++ ...als-with-unknownvalueimplementation.bad.al | 36 +++++++++++++++++++ ...ls-with-unknownvalueimplementation.good.al | 34 ++++++++++++++++++ ...rdinals-with-unknownvalueimplementation.md | 26 ++++++++++++++ .../set-defaultimplementation-on-enum.md | 4 +-- microsoft/skills/review/al-events-review.md | 8 +++-- .../skills/review/al-interfaces-review.md | 10 +++++- 19 files changed, 331 insertions(+), 32 deletions(-) create mode 100644 microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.bad.al create mode 100644 microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.good.al create mode 100644 microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md create mode 100644 microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.bad.al create mode 100644 microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.good.al create mode 100644 microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md create mode 100644 microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.bad.al create mode 100644 microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.good.al create mode 100644 microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md create mode 100644 microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al create mode 100644 microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al create mode 100644 microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md diff --git a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.bad.al b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.bad.al index 19ca979..e146be7 100644 --- a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.bad.al +++ b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.bad.al @@ -1,21 +1,14 @@ -// Demonstration-only AL. Not compiled by CI; illustrates the article. +// Demonstration-only AL. Version 1 exposed PostDocument(SalesHeader). codeunit 50251 "Param Append Bad Sample" { - procedure PostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean) - var - IsHandled: Boolean; + procedure PostDocument(var SalesHeader: Record "Sales Header") begin - IsHandled := false; - // Anti-pattern: 'CalledFromBatch' was inserted before the existing - // IsHandled parameter, shifting it and breaking the argument positions - // every existing subscriber relied on. - OnBeforePostDocument(SalesHeader, CalledFromBatch, IsHandled); - if IsHandled then - exit; + // Existing callers cannot supply the newly required argument. + OnBeforePostDocument(SalesHeader); end; [IntegrationEvent(false, false)] - local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean; var IsHandled: Boolean) + procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean) begin end; } diff --git a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.good.al b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.good.al index 8a13087..6c3a459 100644 --- a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.good.al +++ b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.good.al @@ -1,4 +1,4 @@ -// Demonstration-only AL. Not compiled by CI; illustrates the article. +// Demonstration-only AL. Version 1 had SalesHeader and IsHandled parameters. codeunit 50250 "Param Append Good Sample" { procedure PostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean) @@ -6,15 +6,24 @@ codeunit 50250 "Param Append Good Sample" IsHandled: Boolean; begin IsHandled := false; - // The new 'CalledFromBatch' parameter was appended at the end of the - // existing signature, so existing subscribers needed no re-mapping. - OnBeforePostDocument(SalesHeader, IsHandled, CalledFromBatch); + // Subscribers bind by name, so the new parameter can sit between the + // existing parameters without breaking subscribers that omit it. + OnBeforePostDocument(SalesHeader, CalledFromBatch, IsHandled); if IsHandled then exit; end; [IntegrationEvent(false, false)] - local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; var IsHandled: Boolean; CalledFromBatch: Boolean) + local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean; var IsHandled: Boolean) begin end; } + +codeunit 50252 "Existing Param Subscriber" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Param Append Good Sample", 'OnBeforePostDocument', '', false, false)] + local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; var IsHandled: Boolean) + begin + IsHandled := SalesHeader."No." = ''; + end; +} diff --git a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md index 1f1dc14..b05b020 100644 --- a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md +++ b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md @@ -1,26 +1,26 @@ --- bc-version: [all] domain: events -keywords: [event-parameters, signature, backward-compatibility, append, onbefore, integration-event, versioning] +keywords: [event-parameters, signature, backward-compatibility, public-event, local-event, internal-event, appsourcecop, as0024, as0025] technologies: [al] countries: [w1] application-area: [all] --- -# Add new event parameters at the end +# Event parameter additions depend on publisher access, not position ## Description -Adding a parameter to an existing event publisher changes its signature. Appending the new parameter at the end of the parameter list keeps the change easy to review and track: existing subscribers still bind to the leading parameters, and the diff is a single clean addition. Inserting a parameter in the middle makes diffs noisy and harder to review, and obscures the history of how the signature evolved. New parameters belong after the existing ones. +Event subscribers bind publisher parameters by name and can omit parameters they do not use. A `local` or `internal` Business or Integration event can therefore gain a parameter at any position without breaking subscriber-only consumers; appending is not a compatibility requirement. A public event is also a public procedure that dependent extensions can raise, so adding a required parameter anywhere breaks callers under AppSourceCop AS0024. ## Best Practice -When extending an existing publisher, append the new parameter after all existing ones, including after a trailing `var IsHandled: Boolean` when present. Subscribers that already match keep working against the leading parameters, and the change stays a one-line addition that is trivial to review. +Add a parameter directly only when the shipped event publisher is `local` or `internal`. Place it where the signature is clearest; existing subscribers continue binding the parameters they name. For a public event, keep the original publisher unchanged and introduce a new event with the expanded contract. See sample: `add-new-event-parameters-at-the-end.good.al`. ## Anti Pattern -Inserting a new parameter in the middle of an existing event's signature, shifting every subsequent parameter and making the change noisy and harder to review. Detection: a changed event signature where an added parameter appears before existing parameters rather than at the tail of the list. +Appending a parameter to a public event and assuming its position makes the change compatible. Existing external callers still lack the new required argument. Conversely, do not flag a parameter inserted among existing parameters on a `local` or `internal` Business or Integration event merely because it was not appended. See sample: `add-new-event-parameters-at-the-end.bad.al`. diff --git a/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.bad.al b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.bad.al new file mode 100644 index 0000000..ee7adf8 --- /dev/null +++ b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.bad.al @@ -0,0 +1,14 @@ +// Demonstration-only AL. Version 1 used [IntegrationEvent(true, true, false)]. +codeunit 50531 "Shipment Events Bad" +{ + procedure NotifyShipment(ShipmentNo: Code[20]) + begin + OnShipmentCreated(ShipmentNo); + end; + + // Version 2 mutates all three contract-significant arguments in place. + [IntegrationEvent(false, false, true)] + local procedure OnShipmentCreated(ShipmentNo: Code[20]) + begin + end; +} diff --git a/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.good.al b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.good.al new file mode 100644 index 0000000..6ff7646 --- /dev/null +++ b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.good.al @@ -0,0 +1,21 @@ +// Demonstration-only AL. The Isolated argument requires runtime 9.0 / BC20. +codeunit 50530 "Shipment Events" +{ + procedure NotifyShipment(ShipmentNo: Code[20]) + begin + OnShipmentCreated(ShipmentNo); + OnShipmentCreatedIsolated(ShipmentNo); + end; + + // Preserve the shipped attribute contract. + [IntegrationEvent(true, true, false)] + local procedure OnShipmentCreated(ShipmentNo: Code[20]) + begin + end; + + // Publish a new event for different isolation and sender semantics. + [IntegrationEvent(false, false, true)] + local procedure OnShipmentCreatedIsolated(ShipmentNo: Code[20]) + begin + end; +} diff --git a/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md new file mode 100644 index 0000000..98e0ca7 --- /dev/null +++ b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: events +keywords: [event-attribute, includesender, globalvaraccess, isolated-event, compatibility, integration-event, business-event, appsourcecop, as0021, as0101] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not change shipped event attribute flags + +## Description + +`IncludeSender` and, on Integration events, `GlobalVarAccess` have been event-contract flags since runtime 1.0. Removing sender or global access breaks subscribers, so AppSourceCop AS0021 prevents changing those flags from `true` to `false`. On runtime 9.0 and later (Business Central 2022 release wave 1, BC20), `Isolated` also controls transaction, error, and rollback behavior; AS0101 prevents adding, removing, or changing that argument. + +## Best Practice + +Keep every available attribute argument exactly as shipped. If new subscribers need different sender/global exposure, publish a new event with the desired flags. Apply the same rule to `Isolated` only on BC20 or later, where that argument exists. Raise both events while the original contract is supported, and choose preferred flags only when designing a new event. + +See sample: `do-not-change-shipped-event-attribute-flags.good.al`. + +## Anti Pattern + +Changing a shipped event's `IncludeSender` or `GlobalVarAccess` to modernize its design, including replacing `IncludeSender` with an explicit parameter. On BC20 or later, adding, removing, or toggling `Isolated` is equally contract-significant. Even a change that leaves old subscribers compiling can alter observable execution or exposure; version the event instead. + +See sample: `do-not-change-shipped-event-attribute-flags.bad.al`. diff --git a/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md b/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md index c32bd0d..35d75dc 100644 --- a/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md +++ b/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md @@ -7,20 +7,20 @@ countries: [w1] application-area: [all] --- -# Prefer this over IncludeSender in codeunit events +# Prefer this over IncludeSender in new codeunit events ## Description -Some publishers set `IncludeSender` to `true` on `[IntegrationEvent]` or `[BusinessEvent]` so subscribers receive the publishing object as an implicit sender parameter. From Business Central 2024 release wave 2, a codeunit can instead pass itself explicitly with the `this` keyword as a normal, strongly-typed `Sender` parameter. Explicit passing is clearer at both the publisher and the subscriber: the sender appears in the signature, it is concretely typed to the publishing codeunit, and it avoids the implicit-parameter mechanics of `IncludeSender`. Reserve `IncludeSender = true` for cases where the sender genuinely cannot be passed explicitly. This guidance applies to code targeting Business Central 2024 release wave 2 or later, where the `this` keyword is available. +When designing a new publisher, setting `IncludeSender` to `true` on `[IntegrationEvent]` or `[BusinessEvent]` gives subscribers the publishing object as an implicit sender parameter. From Business Central 2024 release wave 2, a codeunit can instead pass itself explicitly with the `this` keyword as a normal, strongly-typed `Sender` parameter. Explicit passing makes the sender visible and typed in the signature. This is new-event design guidance only: never change `IncludeSender` on an event that has already shipped. ## Best Practice -Declare the publisher `[IntegrationEvent(false, false)]` with an explicit `Sender: Codeunit "…"` parameter and raise it with `this`, for example `OnBeforeProcessOrder(OrderNo, this);`. Subscribers then receive a typed sender they can call directly. +For a new event, declare the publisher `[IntegrationEvent(false, false)]` with an explicit `Sender: Codeunit "…"` parameter and raise it with `this`, for example `OnBeforeProcessOrder(OrderNo, this);`. Subscribers then receive a typed sender they can call directly. See sample: `prefer-this-over-includesender-in-codeunit-events.good.al`. ## Anti Pattern -Relying on `[IntegrationEvent(true, …)]` solely to hand subscribers the publisher instance, where a codeunit could pass `this` explicitly as a typed parameter. Detection: `IncludeSender = true` on a codeunit event whose only purpose is to expose the sender, in code targeting Business Central 2024 release wave 2 or later. +Designing a new codeunit event with `[IntegrationEvent(true, …)]` solely to hand subscribers the publisher instance, where `this` could be passed explicitly as a typed parameter. Do not apply this rule by mutating a shipped event's attribute flags. See sample: `prefer-this-over-includesender-in-codeunit-events.bad.al`. diff --git a/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.bad.al b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.bad.al new file mode 100644 index 0000000..9733ee5 --- /dev/null +++ b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.bad.al @@ -0,0 +1,15 @@ +// Demonstration-only AL. Version 1 exposed var Score as an Integer. +codeunit 50521 "Customer Scoring Events Bad" +{ + procedure ScoreCustomer(CustomerNo: Code[20]; ScoreText: Text) + begin + OnCustomerScored(CustomerNo, ScoreText); + end; + + // 'local' limits raising, not subscription. Renaming Score to ScoreText, + // changing its type, and removing var all break existing subscribers. + [IntegrationEvent(false, false)] + local procedure OnCustomerScored(CustomerNo: Code[20]; ScoreText: Text) + begin + end; +} diff --git a/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.good.al b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.good.al new file mode 100644 index 0000000..10185b7 --- /dev/null +++ b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.good.al @@ -0,0 +1,24 @@ +// Demonstration-only AL. Version 1 had CustomerNo and var Score parameters. +codeunit 50520 "Customer Scoring Events" +{ + procedure ScoreCustomer(CustomerNo: Code[20]; Reason: Text; var Score: Integer) + begin + OnCustomerScored(CustomerNo, Reason, Score); + end; + + // Adding Reason between existing parameters preserves subscriber bindings. + [IntegrationEvent(false, false)] + local procedure OnCustomerScored(CustomerNo: Code[20]; Reason: Text; var Score: Integer) + begin + end; +} + +codeunit 50522 "Existing Scoring Subscriber" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Customer Scoring Events", 'OnCustomerScored', '', false, false)] + local procedure OnCustomerScored(CustomerNo: Code[20]; var Score: Integer) + begin + if CustomerNo = '' then + Score := 0; + end; +} diff --git a/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md new file mode 100644 index 0000000..95a2617 --- /dev/null +++ b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: events +keywords: [local-event, internal-event, event-subscriber, compatibility, access-modifier, integration-event, business-event, parameter-name, var-parameter, appsourcecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Treat local and internal events as subscriber contracts + +## Description + +The `local` and `internal` access modifiers on Business and Integration event publishers restrict who can raise the procedure; they do not prevent dependent extensions from subscribing. Once shipped, the event name and each existing parameter's name, type/subtype, and value-versus-`var` passing mode are compatibility contracts even when the publisher is not public. Parameter order is not a subscriber contract because subscribers bind the parameters they use by name. This differs from `[InternalEvent]`, which is module-only except for modules named by `internalsVisibleTo`. + +## Best Practice + +Preserve a shipped Business or Integration event's identity and every existing parameter's name, type/subtype, and passing mode regardless of the procedure access modifier. AS0025 protects names and types, while AS0063 and AS0077 protect removal and addition of `var`. New parameters may be added at any position on a `local` or `internal` event because subscribers can omit them; public event procedures follow the stricter caller contract described by `add-new-event-parameters-at-the-end`. + +See sample: `treat-local-and-internal-events-as-subscriber-contracts.good.al`. + +## Anti Pattern + +Renaming or removing an existing parameter, changing its type/subtype, or adding/removing its `var` modifier because the event publisher procedure is `local` or `internal`. AppSourceCop checks these subscriber-breaking changes because dependent event subscribers can still bind to the event. Reordering unchanged parameters, or inserting a new parameter among them, is not this anti-pattern. + +See sample: `treat-local-and-internal-events-as-subscriber-contracts.bad.al`. diff --git a/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.bad.al b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.bad.al new file mode 100644 index 0000000..bb630e3 --- /dev/null +++ b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.bad.al @@ -0,0 +1,16 @@ +// Demonstration-only AL. Version 1 shipped with only CalculateAmount(). +interface "I Shipping Quote Bad" +{ + procedure CalculateAmount(): Decimal; + + // Added in version 2: every existing implementer now fails to compile. + procedure CalculateDeliveryDate(): Date; +} + +codeunit 50511 "Existing Shipping Quote" implements "I Shipping Quote Bad" +{ + procedure CalculateAmount(): Decimal + begin + exit(10); + end; +} diff --git a/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.good.al b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.good.al new file mode 100644 index 0000000..a45efa6 --- /dev/null +++ b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.good.al @@ -0,0 +1,23 @@ +// Demonstration-only AL. Interface inheritance requires runtime 14.0 / BC25. +interface "I Shipping Quote" +{ + procedure CalculateAmount(): Decimal; +} + +interface "I Shipping Quote V2" extends "I Shipping Quote" +{ + procedure CalculateDeliveryDate(): Date; +} + +codeunit 50510 "Shipping Quote V2" implements "I Shipping Quote V2" +{ + procedure CalculateAmount(): Decimal + begin + exit(10); + end; + + procedure CalculateDeliveryDate(): Date + begin + exit(Today() + 1); + end; +} diff --git a/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md new file mode 100644 index 0000000..2aceec4 --- /dev/null +++ b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md @@ -0,0 +1,26 @@ +--- +bc-version: [16..] +domain: interfaces +keywords: [published-interface, interface-method, breaking-change, interface-extends, versioned-interface, appsourcecop, as0066] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Extend published interfaces; do not edit them + +## Description + +Adding a method to a shipped interface changes the contract every implementing codeunit must satisfy. Implementers can live in dependent extensions, so the addition breaks code the interface publisher cannot update; AppSourceCop reports AS0066. Interface inheritance is available from runtime 14.0 (Business Central 2024 release wave 2, BC25), but the original interface must remain unchanged. + +## Best Practice + +On BC25 or later, declare a new interface that `extends` the published interface and add the new method there. Existing implementers remain valid for the original contract, while new implementers opt in to the extended contract. For targets BC16 through BC24, where interface inheritance is unavailable, publish a new or versioned sibling interface instead. + +See sample: `extend-published-interfaces-dont-edit-them.good.al`. + +## Anti Pattern + +Adding a procedure directly to an interface that has already shipped. Every dependent implementation must immediately add that procedure, so an otherwise compatible app update breaks its implementers. + +See sample: `extend-published-interfaces-dont-edit-them.bad.al`. diff --git a/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al new file mode 100644 index 0000000..d815bea --- /dev/null +++ b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al @@ -0,0 +1,36 @@ +// Demonstration-only AL. A removed enum-extension value left ordinal 700 in data. +enum 50503 "Delivery Method Bad" implements "I Delivery Method Bad" +{ + Extensible = true; + DefaultImplementation = "I Delivery Method Bad" = "Default Delivery Method Bad"; + + value(0; Default) + { + } +} + +interface "I Delivery Method Bad" +{ + procedure Deliver(); +} + +codeunit 50504 "Default Delivery Method Bad" implements "I Delivery Method Bad" +{ + procedure Deliver() + begin + end; +} + +codeunit 50505 "Delivery Dispatch Bad" +{ + procedure DeliverPersistedValue() + var + DeliveryMethod: Enum "Delivery Method Bad"; + Delivery: Interface "I Delivery Method Bad"; + begin + DeliveryMethod := 700; + // DefaultImplementation does not handle an ordinal that is not declared. + Delivery := DeliveryMethod; + Delivery.Deliver(); + end; +} diff --git a/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al new file mode 100644 index 0000000..9df34ff --- /dev/null +++ b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al @@ -0,0 +1,34 @@ +// Demonstration-only AL. UnknownValueImplementation requires runtime 7.0 / BC18. +interface "I Delivery Method" +{ + procedure Deliver(); +} + +codeunit 50500 "Unknown Delivery Method" implements "I Delivery Method" +{ + procedure Deliver() + begin + Error(UnknownMethodErr); + end; + + var + UnknownMethodErr: Label 'The saved delivery method is no longer installed. Select another method.'; +} + +codeunit 50501 "Default Delivery Method" implements "I Delivery Method" +{ + procedure Deliver() + begin + end; +} + +enum 50502 "Delivery Method" implements "I Delivery Method" +{ + Extensible = true; + DefaultImplementation = "I Delivery Method" = "Default Delivery Method"; + UnknownValueImplementation = "I Delivery Method" = "Unknown Delivery Method"; + + value(0; Default) + { + } +} diff --git a/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md new file mode 100644 index 0000000..d3715e6 --- /dev/null +++ b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md @@ -0,0 +1,26 @@ +--- +bc-version: [18..] +domain: interfaces +keywords: [unknownvalueimplementation, unknown-enum-value, persisted-ordinal, enum-extension, extension-uninstall, interface-fallback] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Handle unknown enum ordinals with UnknownValueImplementation + +## Description + +An enum ordinal can remain in persisted data after the enum extension that declared it is uninstalled. The ordinal is then unknown: it matches no currently declared enum value. `DefaultImplementation` does not cover this case; it covers declared values that have no explicit interface implementation. `UnknownValueImplementation`, available from runtime 7.0 (Business Central 2021 release wave 1, BC18), provides the distinct interface implementation for an unknown ordinal. + +## Best Practice + +On BC18 or later, set `UnknownValueImplementation = = ;` on an enum that implements an interface and can be persisted. Use an implementation that reports a clear domain error or safely contains the unknown state. Keep `DefaultImplementation` separately when declared but unmapped values also need a fallback. + +See sample: `handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al`. + +## Anti Pattern + +Defining only `DefaultImplementation` and assuming it also handles a stored ordinal whose enum value has disappeared. After an enum extension is uninstalled, converting that unknown ordinal to the interface can produce a technical runtime error instead of controlled handling. + +See sample: `handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al`. diff --git a/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md b/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md index af7d5e8..7d2523e 100644 --- a/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md +++ b/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md @@ -11,11 +11,11 @@ application-area: [all] ## Description -An `enum` that `implements` an interface maps each value to a codeunit through the `Implementation` property. But an extensible enum can carry values that set no `Implementation` — values added later by an extension, or a value left intentionally blank. Assigning such a value to an interface variable and calling a method on it fails at runtime unless the enum provides a fallback. The enum-level `DefaultImplementation` property names the codeunit used whenever a value has no explicit `Implementation`, so resolution always yields a usable object. LLMs are generally unaware this property exists and leave the gap open. +An `enum` that `implements` an interface maps each declared value to a codeunit through the `Implementation` property. A declared value, including one supplied by an enum extension, can omit that mapping. Assigning that value to an interface variable then fails at runtime unless the enum provides `DefaultImplementation`. This property is for declared but unmapped values; an ordinal that is no longer declared is a different case covered by `handle-unknown-enum-ordinals-with-unknownvalueimplementation`. ## Best Practice -On any extensible enum that implements an interface, set `DefaultImplementation = = ;` at the enum level, pointing at a safe implementation that does nothing harmful. Values with their own `Implementation` keep using it; declared values without one resolve to the default. For an ordinal that matches no currently declared value — for example persisted data left after an enum extension is uninstalled — runtime 7.0 and later can use `UnknownValueImplementation` as a distinct fallback. Do not recommend that property to apps targeting an earlier runtime. +On any extensible enum that implements an interface, set `DefaultImplementation = = ;` at the enum level, pointing at a safe implementation. Values with their own `Implementation` keep using it; declared values without one resolve to the default. Do not rely on this property for persisted ordinals that match no declared enum value. See sample: `set-defaultimplementation-on-enum.good.al`. diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index 0fe9479..2d7a76d 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-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 codeunits that publish events or host event subscribers, posting/release/validation routines that should expose extension points, and test codeunits that bind subscribers. - The changed procedures and triggers, weighted toward event publisher methods, methods carrying the `[EventSubscriber(...)]` attribute, routines that raise `OnBefore`/`OnAfter` events, and any procedure that calls `BindSubscription`/`UnbindSubscription`. -- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`). +- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `InternalEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `GlobalVarAccess`, `Isolated`, `local`, `internal`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. @@ -53,13 +53,15 @@ The following targeted checks map diff signals to specific `events` articles. Tr - `IsHandled` raised without an immediately preceding `IsHandled := false;`, or one `IsHandled` variable reused across several raises with no reset between them — `initialize-ishandled-to-false-before-publishing`. - `if IsHandled then exit;` in a routine that also raises a paired `OnAfter…` event later, so the after-event is skipped whenever the call is handled — `preserve-onafter-execution-when-ishandled-skips-the-body`. -- A parameter added before existing parameters on a changed event signature instead of appended at the end — `add-new-event-parameters-at-the-end`. +- Any parameter added to a public Business/Integration event procedure, regardless of position; do not flag additions or reordering on `local`/`internal` publishers merely because a new parameter was not appended — `add-new-event-parameters-at-the-end`. +- A shipped Business/Integration event renamed or removed, or an existing parameter renamed, removed, retyped, or changed to/from `var`, based on the mistaken assumption that `local` or `internal` prevents dependent subscription; parameter order alone is not a subscriber-contract violation — `treat-local-and-internal-events-as-subscriber-contracts`. +- Any change to `IncludeSender` or `GlobalVarAccess` on a shipped event at any target version, or to `Isolated` on BC20/runtime 9.0 or later, including a change intended to modernize the publisher — `do-not-change-shipped-event-attribute-flags`. - Publisher names that do not encode firing position (`OnBefore`/`OnAfter` at the boundaries, `OnOnBefore`/`OnAfter` mid-routine) — `name-events-by-publisher-position`. - Two consecutive `OnBefore`/`OnAfter` raises with no logic between them, or a near-duplicate event differing only by an extra parameter — `prefer-reusing-or-extending-existing-events`. - An event raised between `repeat` and `until` inside a record loop — `do-not-publish-events-inside-loops`. - A `temporary` record event parameter whose name does not start with `Temp` — `prefix-temporary-record-event-parameters-with-temp`. - Abbreviated event parameter names (`SalesHdr`, `DocNo`, `Amt`) instead of full table names and spelled-out values — `name-event-parameters-without-abbreviations`. -- `[IntegrationEvent(true, …)]` (`IncludeSender`) on a codeunit event used only to expose the publisher, where `this` could be passed as a typed `Sender` parameter (Business Central 2024 release wave 2 and later) — `prefer-this-over-includesender-in-codeunit-events`. +- `[IntegrationEvent(true, …)]` (`IncludeSender`) on a newly added codeunit event used only to expose the publisher, where `this` could be passed as a typed `Sender` parameter (Business Central 2024 release wave 2 and later) — `prefer-this-over-includesender-in-codeunit-events`. - A `RecordRef` event parameter, or a passed-through `xRec`, where a concrete typed record fits — `avoid-loosely-typed-event-parameters`. - A `var IsHandled` added to a pre-existing event rather than introduced through a new `OnBefore` publisher — `do-not-add-ishandled-to-an-existing-event`. - An `if IsHandled then exit;` whose skipped body performs posting, ledger-entry creation, number-series consumption, or integrity/permission validation — `do-not-bypass-critical-operations-with-ishandled`. diff --git a/microsoft/skills/review/al-interfaces-review.md b/microsoft/skills/review/al-interfaces-review.md index c859b75..65db35e 100644 --- a/microsoft/skills/review/al-interfaces-review.md +++ b/microsoft/skills/review/al-interfaces-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 `interface` objects, codeunits and enums declared with the `implements` keyword, and consumers that declare or assign an `Interface` variable. - The changed procedures and triggers, weighted toward factory or dispatch routines that resolve a variant to behaviour, setter-injection procedures that take an `Interface` parameter, and `case`-over-enum blocks that select between strategies. -- Tokens extracted from the diff that relate to interfaces and enum-backed implementation (`interface`, `implements`, `Implementation`, `DefaultImplementation`, `UnknownValueImplementation`, `enum`, `Extensible`, `Interface`, `case`, and the `case of` anti-pattern signal — a `case` over an enum value whose branches choose between variant computations). +- Tokens extracted from the diff that relate to interfaces and enum-backed implementation (`interface`, `extends`, `implements`, `Implementation`, `DefaultImplementation`, `UnknownValueImplementation`, `enum`, `Extensible`, `Interface`, `case`, and the `case of` anti-pattern signal — a `case` over an enum value whose branches choose between variant computations). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. @@ -47,6 +47,14 @@ Once the candidate worklist is known, resolve layer-precedence conflicts per REA When the post-conflict worklist is empty because no applicable interfaces knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable interfaces knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. +### Interface-compatibility checks + +The following targeted checks map diff signals to specific `interfaces` articles. Treat each as a candidate-selection cue: when the signal appears in the changed code, add the named article to the worklist and evaluate it in Action. + +- `DefaultImplementation` used as the only fallback where a persisted ordinal may no longer match any declared enum value, or a persisted enum lacks `UnknownValueImplementation` on BC18 or later — `handle-unknown-enum-ordinals-with-unknownvalueimplementation`. +- A method added directly to an interface that exists in the baseline, instead of adding a BC25+ interface that `extends` it or a versioned sibling for older targets — `extend-published-interfaces-dont-edit-them`. +- A declared enum value with no `Implementation` and no enum-level `DefaultImplementation` — `set-defaultimplementation-on-enum`. + ## Action For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows: From e0ebdd35c70686f53e7e78bf40abe5982f65ee1e Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 14 Jul 2026 12:52:56 +0200 Subject: [PATCH 21/86] Add lifecycle error and privacy knowledge (#99) * Add lifecycle error and privacy knowledge Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 95c06ad8-377d-4faa-8d07-06300b1c81ec * Fix lifecycle privacy review findings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a26cd6d6-ff49-433e-bc53-f645c455ebdd * Refine lifecycle privacy retrieval Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 95c06ad8-377d-4faa-8d07-06300b1c81ec * Make review gates explicit Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 95c06ad8-377d-4faa-8d07-06300b1c81ec --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...internal-vs-client-for-diagnostics.good.al | 1 - ...type-internal-vs-client-for-diagnostics.md | 4 +- ...ction-return-disables-try-semantics.bad.al | 17 +++++ ...tion-return-disables-try-semantics.good.al | 18 ++++++ ...yfunction-return-disables-try-semantics.md | 26 ++++++++ ...emetry-classification-and-errortype.bad.al | 12 ++++ ...metry-classification-and-errortype.good.al | 15 +++++ ...-telemetry-classification-and-errortype.md | 26 ++++++++ ...lemetry-logerror-implicit-errortext.bad.al | 25 ++++++++ ...emetry-logerror-implicit-errortext.good.al | 28 +++++++++ ...retelemetry-logerror-implicit-errortext.md | 26 ++++++++ ...k-only-triggers-do-not-migrate-data.bad.al | 32 ++++++++++ ...-only-triggers-do-not-migrate-data.good.al | 63 +++++++++++++++++++ ...check-only-triggers-do-not-migrate-data.md | 26 ++++++++ ...irst-install-dataversion-zero-check.bad.al | 3 +- .../first-install-dataversion-zero-check.md | 7 ++- ...ode-does-not-run-on-version-upgrade.bad.al | 20 ++++++ ...de-does-not-run-on-version-upgrade.good.al | 48 ++++++++++++++ ...ll-code-does-not-run-on-version-upgrade.md | 26 ++++++++ ...inimize-onvalidate-upgrade-triggers.bad.al | 17 ++++- ...nimize-onvalidate-upgrade-triggers.good.al | 26 ++++---- .../minimize-onvalidate-upgrade-triggers.md | 10 +-- .../skills/review/al-error-handling-review.md | 5 +- microsoft/skills/review/al-privacy-review.md | 8 ++- microsoft/skills/review/al-upgrade-review.md | 9 ++- 25 files changed, 459 insertions(+), 39 deletions(-) create mode 100644 microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.bad.al create mode 100644 microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.good.al create mode 100644 microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md create mode 100644 microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.bad.al create mode 100644 microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.good.al create mode 100644 microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md create mode 100644 microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.bad.al create mode 100644 microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.good.al create mode 100644 microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md create mode 100644 microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.bad.al create mode 100644 microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.good.al create mode 100644 microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md create mode 100644 microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.bad.al create mode 100644 microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.good.al create mode 100644 microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md diff --git a/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.good.al b/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.good.al index 9791909..2dcd2d2 100644 --- a/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.good.al +++ b/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.good.al @@ -7,7 +7,6 @@ codeunit 50190 "Error Type Good Sample" if not BucketInitialized(BucketId) then begin InternalErr.ErrorType := ErrorType::Internal; InternalErr.Message := StrSubstNo('Ledger bucket %1 was not initialized before posting.', BucketId); - InternalErr.DetailedMessage := 'Internal invariant violated. Inspect the call stack captured in telemetry.'; Error(InternalErr); end; end; diff --git a/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md b/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md index 7264ad6..127fa50 100644 --- a/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md +++ b/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [14..] domain: error-handling keywords: [errorinfo, errortype, internal, client, telemetry, diagnostics, generic-message] technologies: [al] @@ -15,7 +15,7 @@ application-area: [all] ## Best Practice -Reserve `ErrorType::Internal` for errors the user cannot act on: corrupted internal state, an unreachable branch, a contract a caller violated. Set a precise, detail-rich `Message` and `DetailedMessage` for telemetry, raise it via `Error(ErrorInfo)`, and let the platform show the user a generic dialog. Keep `ErrorType::Client` (or a plain `Error`) for failures the user is expected to read and resolve — validation messages, missing setup, business-rule violations. The test is simple: if the message only makes sense to a developer, mark it `Internal`. +Reserve `ErrorType::Internal` for errors the user cannot act on: corrupted internal state, an unreachable branch, a contract a caller violated. Set a precise, detail-rich `Message` for telemetry, raise it via `Error(ErrorInfo)`, and let the platform show the user a generic dialog. Keep `ErrorType::Client` (or a plain `Error`) for failures the user is expected to read and resolve — validation messages, missing setup, business-rule violations. The test is simple: if the message only makes sense to a developer, mark it `Internal`. See sample: `errortype-internal-vs-client-for-diagnostics.good.al`. diff --git a/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.bad.al b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.bad.al new file mode 100644 index 0000000..8d6df99 --- /dev/null +++ b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.bad.al @@ -0,0 +1,17 @@ +codeunit 50301 "Try Return Bad" +{ + procedure ImportDocument() + begin + // Ignoring the Boolean result makes this an ordinary, throwing call. + TryImportDocument(); + end; + + [TryFunction] + local procedure TryImportDocument() + begin + Error(SourceRejectedErr); + end; + + var + SourceRejectedErr: Label 'The source document was rejected.'; +} diff --git a/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.good.al b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.good.al new file mode 100644 index 0000000..d369d27 --- /dev/null +++ b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.good.al @@ -0,0 +1,18 @@ +codeunit 50300 "Try Return Good" +{ + procedure ImportDocument() + begin + if not TryImportDocument() then + Error(ImportFailedErr); + end; + + [TryFunction] + local procedure TryImportDocument() + begin + Error(SourceRejectedErr); + end; + + var + ImportFailedErr: Label 'The document could not be imported.'; + SourceRejectedErr: Label 'The source document was rejected.'; +} diff --git a/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md new file mode 100644 index 0000000..f61553d --- /dev/null +++ b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md @@ -0,0 +1,26 @@ +--- +bc-version: [13..] +domain: error-handling +keywords: [tryfunction, try-method, boolean-return, ignored-return-value, error-propagation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Consume a TryFunction return value to enable try semantics + +## Description + +A procedure marked `[TryFunction]` catches errors only when the caller uses its Boolean return value. An assignment or conditional makes the invocation a try-method call; a bare call is treated as an ordinary procedure call and exposes errors as usual. The attribute alone does not make every invocation non-throwing. + +## Best Practice + +Consume the result directly: assign it to a Boolean or use the call in an `if` condition. Handle `false` immediately while the last-error state still describes that failure. + +See sample: `ignored-tryfunction-return-disables-try-semantics.good.al`. + +## Anti Pattern + +Calling a `[TryFunction]` procedure as a standalone statement and assuming the attribute suppresses its errors. The call has ordinary error semantics because its Boolean result is ignored. + +See sample: `ignored-tryfunction-return-disables-try-semantics.bad.al`. diff --git a/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.bad.al b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.bad.al new file mode 100644 index 0000000..6fc5b06 --- /dev/null +++ b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.bad.al @@ -0,0 +1,12 @@ +codeunit 50308 "ErrorInfo Privacy Bad" +{ + procedure RaiseSynchronizationError(Customer: Record Customer) + var + FailureInfo: ErrorInfo; + begin + FailureInfo.Message := StrSubstNo('Synchronization failed for %1.', Customer."E-Mail"); + FailureInfo.DataClassification := DataClassification::SystemMetadata; + FailureInfo.ErrorType := ErrorType::Internal; + Error(FailureInfo); + end; +} diff --git a/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.good.al b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.good.al new file mode 100644 index 0000000..1da3c1f --- /dev/null +++ b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.good.al @@ -0,0 +1,15 @@ +codeunit 50307 "ErrorInfo Privacy Good" +{ + procedure RaiseSynchronizationError() + var + FailureInfo: ErrorInfo; + begin + FailureInfo.Message := SynchronizationFailedErr; + FailureInfo.DataClassification := DataClassification::SystemMetadata; + FailureInfo.ErrorType := ErrorType::Client; + Error(FailureInfo); + end; + + var + SynchronizationFailedErr: Label 'The synchronization could not be completed.'; +} diff --git a/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md new file mode 100644 index 0000000..ce1b684 --- /dev/null +++ b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md @@ -0,0 +1,26 @@ +--- +bc-version: [14..] +domain: privacy +keywords: [errorinfo, errorinfo-message, errorinfo-dataclassification, errorinfo-errortype, errorinfo-detailedmessage, copy-details, telemetry] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Review each ErrorInfo text surface by its actual exposure + +## Description + +Runtime 3.0 (BC 14) provides `ErrorInfo.Message`, `DataClassification`, and `ErrorType`. `Message` is sent to telemetry; with `ErrorType::Client` it is also the primary client message, while `ErrorType::Internal` replaces it in the client with a generic message but still sends the specified text to telemetry. `DataClassification` classifies the content in `Message`; it does not make incorrectly classified personal data safe. Runtime 8.0 (BC 19) adds `DetailedMessage`, which is omitted from the primary message but included in the error dialog's **Copy details** content. + +## Best Practice + +Keep `Message` stable and classify its actual content. Choose `ErrorType` for client usability, not as a telemetry privacy boundary. On BC 19 and later, put only support-safe technical context in `DetailedMessage`, because a user can copy it from the dialog. The samples use only members available at the BC 14 article floor. + +See sample: `errorinfo-telemetry-classification-and-errortype.good.al`. + +## Anti Pattern + +Marking a dynamic customer-bearing `Message` as `SystemMetadata`, or assuming `ErrorType::Internal` keeps it out of telemetry. On BC 19 and later, the same anti-pattern includes placing secrets or personal data in `DetailedMessage` because it is not the primary dialog text. + +See sample: `errorinfo-telemetry-classification-and-errortype.bad.al`. diff --git a/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.bad.al b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.bad.al new file mode 100644 index 0000000..6906077 --- /dev/null +++ b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.bad.al @@ -0,0 +1,25 @@ +codeunit 50310 "LogError Privacy Bad" +{ + procedure SendInvoice() + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + CustomDimensions: Dictionary of [Text, Text]; + ErrorCallStack: Text; + ErrorText: Text; + begin + if TrySendInvoice() then + exit; + + ErrorText := GetLastErrorText(); + ErrorCallStack := GetLastErrorCallStack(); + CustomDimensions.Add('Operation', 'SendInvoice'); + FeatureTelemetry.LogError('0000FT2', 'Invoice exchange', 'Sending invoice', + ErrorText, ErrorCallStack, CustomDimensions); + end; + + [TryFunction] + local procedure TrySendInvoice() + begin + Error('Invoice %1 for %2 could not be sent.', 'INV-1001', 'user@example.com'); + end; +} diff --git a/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.good.al b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.good.al new file mode 100644 index 0000000..cae02be --- /dev/null +++ b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.good.al @@ -0,0 +1,28 @@ +codeunit 50309 "LogError Privacy Good" +{ + procedure SendInvoice() + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + CustomDimensions: Dictionary of [Text, Text]; + ErrorCallStack: Text; + ErrorText: Text; + begin + if TrySendInvoice() then + exit; + + ErrorText := GetLastErrorText(true); + ErrorCallStack := GetLastErrorCallStack(); + CustomDimensions.Add('Operation', 'SendInvoice'); + FeatureTelemetry.LogError('0000FT1', 'Invoice exchange', 'Sending invoice', + ErrorText, ErrorCallStack, CustomDimensions); + end; + + [TryFunction] + local procedure TrySendInvoice() + begin + Error(SendFailedErr); + end; + + var + SendFailedErr: Label 'The invoice could not be sent.'; +} diff --git a/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md new file mode 100644 index 0000000..863f3a8 --- /dev/null +++ b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md @@ -0,0 +1,26 @@ +--- +bc-version: [18..] +domain: privacy +keywords: [featuretelemetry, logerror, errortext, errorcallstack, alerrortext, alerrorcallstack, customdimensions] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# FeatureTelemetry.LogError emits more than caller custom dimensions + +## Description + +`FeatureTelemetry.LogError` emits its `ErrorText` as the telemetry message and adds it as `alErrorText`. The overloads with `ErrorCallStack` also add `alErrorCallStack`. These dimensions are produced in addition to the caller-supplied `CustomDimensions` dictionary, and the Feature Telemetry implementation sends the event as `SystemMetadata`. + +## Best Practice + +Review the dedicated error arguments as telemetry payload. Capture `GetLastErrorText(true)` when scrubbed platform error text is sufficient, and pass `GetLastErrorCallStack()` only as a call stack. Keep custom dimensions non-personal too. + +See sample: `featuretelemetry-logerror-implicit-errortext.good.al`. + +## Anti Pattern + +Approving a `LogError` call because its explicit dictionary contains only safe values while it passes unsanitized `GetLastErrorText()` or arbitrary context through `ErrorText` or `ErrorCallStack`. Those arguments become telemetry dimensions outside the dictionary. + +See sample: `featuretelemetry-logerror-implicit-errortext.bad.al`. diff --git a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.bad.al b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.bad.al new file mode 100644 index 0000000..af1dabf --- /dev/null +++ b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.bad.al @@ -0,0 +1,32 @@ +codeunit 50303 "Upgrade Phases Bad" +{ + Subtype = Upgrade; + + trigger OnCheckPreconditionsPerCompany() + begin + // A precondition check must not repair the data it is checking. + RenamePostingGroup(); + end; + + trigger OnValidateUpgradePerCompany() + begin + // Validation must not perform a migration omitted from OnUpgrade. + MigrateCustomerPostingGroups(); + end; + + local procedure RenamePostingGroup() + var + CustomerPostingGroup: Record "Customer Posting Group"; + begin + if CustomerPostingGroup.Get('OLD') then + CustomerPostingGroup.Rename('NEW'); + end; + + local procedure MigrateCustomerPostingGroups() + var + Customer: Record Customer; + begin + Customer.SetRange("Customer Posting Group", 'OLD'); + Customer.ModifyAll("Customer Posting Group", 'NEW'); + end; +} diff --git a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.good.al b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.good.al new file mode 100644 index 0000000..6929741 --- /dev/null +++ b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.good.al @@ -0,0 +1,63 @@ +codeunit 50302 "Upgrade Phases Good" +{ + Subtype = Upgrade; + + trigger OnCheckPreconditionsPerCompany() + begin + CheckTargetPostingGroup(); + end; + + trigger OnUpgradePerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(CustomerPostingGroupTag()) then + exit; + + MigrateCustomerPostingGroups(); + UpgradeTag.SetUpgradeTag(CustomerPostingGroupTag()); + end; + + trigger OnValidateUpgradePerCompany() + begin + CheckLegacyPostingGroupsRemoved(); + end; + + local procedure CheckTargetPostingGroup() + var + CustomerPostingGroup: Record "Customer Posting Group"; + begin + if not CustomerPostingGroup.Get('NEW') then + Error(TargetGroupMissingErr); + end; + + local procedure MigrateCustomerPostingGroups() + var + Customer: Record Customer; + begin + Customer.SetRange("Customer Posting Group", 'OLD'); + if Customer.FindSet(true) then + repeat + Customer.Validate("Customer Posting Group", 'NEW'); + Customer.Modify(true); + until Customer.Next() = 0; + end; + + local procedure CheckLegacyPostingGroupsRemoved() + var + Customer: Record Customer; + begin + Customer.SetRange("Customer Posting Group", 'OLD'); + if not Customer.IsEmpty() then + Error(MigrationIncompleteErr); + end; + + local procedure CustomerPostingGroupTag(): Code[250] + begin + exit('MS-50302-CustomerPostingGroup-20260714'); + end; + + var + MigrationIncompleteErr: Label 'The legacy customer posting group was not migrated.'; + TargetGroupMissingErr: Label 'Customer posting group NEW must exist before the upgrade.'; +} diff --git a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md new file mode 100644 index 0000000..30f6ca7 --- /dev/null +++ b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [on-check-preconditions, on-validate-upgrade, on-upgrade, read-only-check, data-migration] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Upgrade check triggers do not migrate data + +## Description + +`OnCheckPreconditionsPerCompany`/`PerDatabase` run before the upgrade to verify that it can start. `OnValidateUpgradePerCompany`/`PerDatabase` run after upgrade logic to verify that it succeeded. Treat both phases as read-only checks. The `OnUpgradePerCompany`/`PerDatabase` phase is where the platform expects actual data transformation. + +## Best Practice + +Have check triggers call query-only helpers that raise an error when an invariant fails. Put every `Insert`, `Modify`, `Delete`, `Rename`, `DataTransfer`, and other migration write behind helpers called from the matching `OnUpgrade...` trigger. + +See sample: `check-only-triggers-do-not-migrate-data.good.al`. + +## Anti Pattern + +Repairing data in `OnCheckPreconditions...` or finishing migration in `OnValidateUpgrade...`. Those writes blur the phase contract and make a check alter the state it is supposed to assess. + +See sample: `check-only-triggers-do-not-migrate-data.bad.al`. diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al index e3381ad..da501e6 100644 --- a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al +++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al @@ -4,8 +4,7 @@ codeunit 50211 "Install My Extension" trigger OnInstallAppPerCompany() begin - // No DataVersion() guard — this runs on every reinstall and upgrade - // path, duplicating seed rows. + // No DataVersion() guard: a reinstall duplicates seed rows. SeedDefaultRows(); end; diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md index 260b15b..6324836 100644 --- a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md +++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md @@ -11,20 +11,21 @@ application-area: [all] ## 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. +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')`. During reinstall, `DataVersion()` identifies the previously installed data version. The `OnInstallAppPerCompany` trigger uses this distinction to separate a brand-new install from a reinstall. Ordinary version upgrades do not run install code. ## 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. +In `OnInstallAppPerCompany`, fetch the current `ModuleInfo` via `NavApp.GetCurrentModuleInfo`, compare `AppInfo.DataVersion()` to `Version.Create('0.0.0.0')`, and run first-install seed logic only when they match. On a non-zero data version, follow the reinstall path or exit. 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. +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, first-install seed code can run again and duplicate 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`. +- `install-code-does-not-run-on-version-upgrade.md` — ordinary version upgrades invoke upgrade code, not install code. diff --git a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.bad.al b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.bad.al new file mode 100644 index 0000000..bb19d21 --- /dev/null +++ b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.bad.al @@ -0,0 +1,20 @@ +codeunit 50306 "My App Install Only" +{ + Subtype = Install; + + trigger OnInstallAppPerCompany() + begin + // A normal version upgrade never invokes this migration. + MigrateLegacySetup(); + end; + + local procedure MigrateLegacySetup() + var + MyAppSetup: Record "My App Setup"; + begin + if MyAppSetup.Get() then begin + MyAppSetup."Configuration Version" := 2; + MyAppSetup.Modify(true); + end; + end; +} diff --git a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.good.al b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.good.al new file mode 100644 index 0000000..c77f3cf --- /dev/null +++ b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.good.al @@ -0,0 +1,48 @@ +codeunit 50304 "My App Install" +{ + Subtype = Install; + + trigger OnInstallAppPerCompany() + begin + InitializeSetup(); + end; + + local procedure InitializeSetup() + var + MyAppSetup: Record "My App Setup"; + begin + if MyAppSetup.IsEmpty() then + MyAppSetup.Insert(true); + end; +} + +codeunit 50305 "My App Upgrade" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(ConfigurationVersionTag()) then + exit; + + MigrateLegacySetup(); + UpgradeTag.SetUpgradeTag(ConfigurationVersionTag()); + end; + + local procedure MigrateLegacySetup() + var + MyAppSetup: Record "My App Setup"; + begin + if MyAppSetup.Get() then begin + MyAppSetup."Configuration Version" := 2; + MyAppSetup.Modify(true); + end; + end; + + local procedure ConfigurationVersionTag(): Code[250] + begin + exit('MS-50305-ConfigurationVersion-20260714'); + end; +} diff --git a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md new file mode 100644 index 0000000..12f432f --- /dev/null +++ b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [install-codeunit, subtype-install, on-install-app, version-upgrade, upgrade-codeunit] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Install code does not run during a version upgrade + +## Description + +An install codeunit runs when an extension is installed for the first time or an uninstalled version is installed again. Installing a higher extension version through the data-upgrade operation does not invoke `OnInstallAppPerCompany` or `OnInstallAppPerDatabase`. Ordinary version-to-version migration is dispatched only through upgrade codeunits. + +## Best Practice + +Use `Subtype = Install` for first-install and reinstall initialization. Put version migration in a separate `Subtype = Upgrade` codeunit and enter it from `OnUpgradePerCompany` or `OnUpgradePerDatabase`. + +See sample: `install-code-does-not-run-on-version-upgrade.good.al`. + +## Anti Pattern + +Putting a schema or data migration only in an install trigger and expecting it to run when a higher app version is upgraded. The migration is never invoked on that path. + +See sample: `install-code-does-not-run-on-version-upgrade.bad.al`. diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al index 69994e6..35b848b 100644 --- a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al +++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al @@ -4,10 +4,21 @@ codeunit 50235 "Upgrade With Validation" trigger OnValidateUpgradePerCompany() begin - // No skip logic and no written justification — full-table validation - // runs on every single upgrade pass. + // A full-table scan repeats on every upgrade. ValidateAllCustomers(); end; - local procedure ValidateAllCustomers() begin end; + local procedure ValidateAllCustomers() + var + Customer: Record Customer; + begin + if Customer.FindSet() then + repeat + if Customer."Customer Posting Group" = 'OLD' then + Error(MigrationIncompleteErr); + until Customer.Next() = 0; + end; + + var + MigrationIncompleteErr: Label 'The legacy customer posting group was not migrated.'; } diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al index 9a5a83b..8680775 100644 --- a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al +++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al @@ -3,23 +3,19 @@ codeunit 50234 "Upgrade With Validation" Subtype = Upgrade; trigger OnValidateUpgradePerCompany() + begin + CheckNoLegacyPostingGroups(); + end; + + local procedure CheckNoLegacyPostingGroups() var - UpgradeTag: Codeunit "Upgrade Tag"; + Customer: Record Customer; 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()); + Customer.SetRange("Customer Posting Group", 'OLD'); + if not Customer.IsEmpty() then + Error(MigrationIncompleteErr); end; - local procedure ValidateAllCustomers() begin end; - - local procedure MyValidationUpgradeTag(): Code[250] - begin - exit('MS-123456-CustomerValidation-20240101'); - end; + var + MigrationIncompleteErr: Label 'The legacy customer posting group was not migrated.'; } diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md index 2c02def..b9e5e13 100644 --- a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md +++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md @@ -1,26 +1,26 @@ --- bc-version: [all] domain: upgrade -keywords: [on-validate-upgrade-per-company, performance-impact, skip-logic, justification, upgrade-tag] +keywords: [on-validate-upgrade-per-company, performance-impact, bounded-query, justification, read-only-check] technologies: [al] countries: [w1] application-area: [all] --- -# Performance-impacting upgrade triggers need justification and skip logic +# Keep upgrade validation checks bounded ## 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. +Triggers such as `OnValidateUpgradePerCompany` run on every upgrade pass. A full-table scan or cross-table validation therefore adds cost to every upgrade of every tenant. Validation is a read-only lifecycle check, so it cannot make itself one-time by writing an upgrade tag. ## 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. +Filter directly to invalid rows and use `IsEmpty` or another bounded existence check where possible. If a broad validation is unavoidable, document the invariant that requires it and keep all data changes in `OnUpgrade...`. 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. +Reading every record in `OnValidateUpgradePerCompany` when a filtered existence check can prove the same invariant. The scan repeats on every upgrade. See sample: `minimize-onvalidate-upgrade-triggers.bad.al`. diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index 91228aa..50bd9e9 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-review.md @@ -38,8 +38,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 codeunits that post or validate, tables and table extensions with `OnValidate` triggers, and any procedure that raises errors or orchestrates a batch over records. -- The changed procedures and triggers, weighted toward `OnValidate`/`OnInsert`/`OnModify` triggers, posting and validation routines, and procedures attributed with `[ErrorBehavior(...)]`. -- Tokens extracted from the diff that relate to error surfacing and diagnostics (`Error`, `ErrorInfo`, `Title`, `Message`, `DetailedMessage`, `AddAction`, `AddNavigationAction`, `RecordId`, `PageNo`, `ErrorBehavior`, `Collect`, `HasCollectedErrors`, `GetCollectedErrors`, `ClearCollectedErrors`, `ErrorType`, `Internal`, `Client`). +- The changed procedures and triggers, weighted toward `OnValidate`/`OnInsert`/`OnModify` triggers, posting and validation routines, and procedures attributed with `[ErrorBehavior(...)]` or `[TryFunction]`. +- Tokens extracted from the diff that relate to error surfacing and diagnostics (`Error`, `ErrorInfo`, `Title`, `Message`, `DetailedMessage`, `AddAction`, `AddNavigationAction`, `RecordId`, `PageNo`, `ErrorBehavior`, `Collect`, `HasCollectedErrors`, `GetCollectedErrors`, `ClearCollectedErrors`, `ErrorType`, `Internal`, `Client`, `TryFunction`, `GetLastErrorText`, Boolean assignment). +- Resolve changed standalone call targets; when the target declaration has `[TryFunction]`, worklist the ignored-return rule even if the declaration itself is unchanged. Only assignment and conditional use activate try semantics. A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index bf60f5d..2cd4005 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -38,10 +38,12 @@ 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. 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`, `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`). +- The changed procedures and triggers, weighted toward those that call `Error`, construct `ErrorInfo`, call `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`/`GetLastErrorCallStack`, `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`, `ErrorInfo`, `GetLastErrorText`, `GetLastErrorCallStack`, `TelemetryScope`, `FeatureTelemetry`, `CustomDimensions`, `LogUsage`, `LogUptake`, `LogError`, `ErrorText`, `ErrorCallStack`, `alErrorText`, `alErrorCallStack`, `HybridSL`, `HybridGP`, `HybridBC`). +- Treat `ErrorInfo.Message`, `ErrorInfo.DataClassification`, `ErrorInfo.ErrorType`, and `ErrorInfo.DetailedMessage` as qualified member signals: accept a call or assignment only when symbol resolution proves that its receiver expression or variable has type `ErrorInfo`. Normalize those accesses to `errorinfo-message`, `errorinfo-dataclassification`, `errorinfo-errortype`, and `errorinfo-detailedmessage` retrieval tokens. Bare `Message` or `DataClassification` tokens MUST NOT trigger this article; do not emit the qualified tokens for `Message(...)` dialog calls, table or table-field `DataClassification` properties, or similarly named members on other types. Resolve the receiver's declaration from the containing object when it is outside the changed hunk. +- Worklist ErrorInfo privacy guidance only from those typed `ErrorInfo` member tokens or from construction of an `ErrorInfo` value. For every `FeatureTelemetry.LogError`, inspect the dedicated error text and call-stack arguments in addition to explicit custom dimensions. -A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. +A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Apply the topic-specific gates above after this overlap check; in particular, bare `Message` and `DataClassification` tokens cannot admit ErrorInfo guidance. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. 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`. diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md index 3b91d67..c087585 100644 --- a/microsoft/skills/review/al-upgrade-review.md +++ b/microsoft/skills/review/al-upgrade-review.md @@ -38,8 +38,11 @@ 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`, `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`). +- The changed triggers and procedures, weighted toward `OnCheckPreconditionsPerCompany`/`PerDatabase`, `OnUpgradePerCompany`/`PerDatabase`, `OnValidateUpgradePerCompany`/`PerDatabase`, `OnInstallAppPerCompany`/`PerDatabase`, the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers, and helper procedures transitively reachable from those entry points. +- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Subtype = Install`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnCheckPreconditions`, `OnUpgrade`, `OnValidateUpgrade`, `OnInstallApp`, `DataTransfer`, `CopyFields`, `Insert`, `Modify`, `Delete`, `Rename`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `DataVersion`, `ExecutionContext`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`). +- For each `OnCheckPreconditions...` and `OnValidateUpgrade...` trigger, build the best available call graph from surrounding unchanged source as well as changed hunks, tracing resolved calls through reachable local or internal helpers. Worklist the check-only rule when a database write occurs either directly in the trigger or in any helper procedure reachable from it. Writes include `Insert`, `Modify`, `ModifyAll`, `Delete`, `DeleteAll`, `Rename`, and `DataTransfer`. Also perform the reverse check when a PR changes a writing helper body: worklist the rule when that helper is invoked directly or transitively by an unchanged check or validation trigger. +- Treat a direct write or a fully resolved call chain as high-confidence evidence. When cross-object dispatch, unavailable declarations, or an incomplete call graph prevents proving the complete chain, cap confidence at `medium`, name the unresolved edge in the finding, and do not claim a violation without a resolved path from a check or validation trigger to a write. +- Worklist the install-versus-upgrade rule when migration helpers are reachable only from an install codeunit. A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no upgrade-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. @@ -57,7 +60,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` Set `confidence` to: -- `high` when the detection is based on an unambiguous pattern match. +- `high` when the detection is based on an unambiguous pattern match and any required helper reachability is fully established. - `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. - `low` when the finding is an advisory derived only from applicability. From 0bb1065bc3666faa876d1d3170363b1286fed0b7 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 14 Jul 2026 12:53:26 +0200 Subject: [PATCH 22/86] Add P0 integration and control add-in runtime guidance (#100) * Add P0 integration and control add-in guidance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02baffe8-0600-430d-81fa-a9993685e7cb * Correct API part multiplicity guidance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02baffe8-0600-430d-81fa-a9993685e7cb * Refine API part multiplicity guidance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1c37924e-9749-4e63-9d58-bd73d659f736 --------- Co-authored-by: Jesper Schulz-Wedde --- ...resource-ajax-needs-withcredentials.bad.js | 3 + ...esource-ajax-needs-withcredentials.good.js | 8 + ...age-resource-ajax-needs-withcredentials.md | 30 ++++ ...-throttle-al-calls-and-payload-size.bad.js | 8 + ...throttle-al-calls-and-payload-size.good.js | 74 +++++++++ ...ddin-throttle-al-calls-and-payload-size.md | 30 ++++ ...ts-on-systemid-and-set-multiplicity.bad.al | 80 ++++++++++ ...s-on-systemid-and-set-multiplicity.good.al | 151 ++++++++++++++++++ ...-parts-on-systemid-and-set-multiplicity.md | 30 ++++ ...ibility-and-validationtoken-renewal.bad.al | 22 +++ ...ibility-and-validationtoken-renewal.bad.js | 4 + ...bility-and-validationtoken-renewal.good.al | 28 ++++ ...bility-and-validationtoken-renewal.good.js | 11 ++ ...eligibility-and-validationtoken-renewal.md | 30 ++++ microsoft/skills/review/al-ui-review.md | 12 +- .../skills/review/al-web-services-review.md | 17 +- 16 files changed, 526 insertions(+), 12 deletions(-) create mode 100644 microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.bad.js create mode 100644 microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.good.js create mode 100644 microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md create mode 100644 microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.bad.js create mode 100644 microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.good.js create mode 100644 microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.md create mode 100644 microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.bad.al create mode 100644 microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.good.al create mode 100644 microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md create mode 100644 microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.al create mode 100644 microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.js create mode 100644 microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.al create mode 100644 microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.js create mode 100644 microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md diff --git a/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.bad.js b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.bad.js new file mode 100644 index 0000000..d36c363 --- /dev/null +++ b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.bad.js @@ -0,0 +1,3 @@ +function loadPackagedTemplate(url) { + return $.get(url).done(renderTemplate); +} diff --git a/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.good.js b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.good.js new file mode 100644 index 0000000..781c23d --- /dev/null +++ b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.good.js @@ -0,0 +1,8 @@ +function loadPackagedTemplate(url) { + return $.ajax({ + url: url, + xhrFields: { + withCredentials: true + } + }).done(renderTemplate); +} diff --git a/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md new file mode 100644 index 0000000..d5edd39 --- /dev/null +++ b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: ui +keywords: [control-add-in, packaged-resource, ajax, withcredentials, xhrfields, jquery] +technologies: [javascript] +countries: [w1] +application-area: [all] +--- + +# Load packaged control add-in resources with credentialed AJAX + +## Description + +JavaScript in a Business Central control add-in can load a static resource from its extension package with AJAX, but the request needs the Business Central context and cookies. Set `xhrFields.withCredentials = true`; shorthand calls such as `$.get` omit that setting and can work during development yet fail in production. + +## Best Practice + +Use an AJAX form that explicitly enables `withCredentials` whenever a control add-in requests a packaged static resource. Keep this rule scoped to resources served from the add-in package; it is not generic advice to attach credentials to arbitrary external requests. + +See sample: `control-addin-package-resource-ajax-needs-withcredentials.good.js`. + +## Anti Pattern + +Using `$.get(url)` or an `XMLHttpRequest` without `withCredentials = true` to retrieve package content. The request can lack the context and cookies required by the Business Central service. + +See sample: `control-addin-package-resource-ajax-needs-withcredentials.bad.js`. + +## Source + +[Control add-in object: Loading static resources using AJAX requests](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-control-addin-object#loading-static-resources-using-ajax-requests). diff --git a/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.bad.js b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.bad.js new file mode 100644 index 0000000..9bd6ebf --- /dev/null +++ b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.bad.js @@ -0,0 +1,8 @@ +function startSendingRows(rows) { + window.setInterval(() => { + Microsoft.Dynamics.NAV.InvokeExtensibilityMethod( + "StoreRows", + [JSON.stringify(rows)], + false); + }, 100); +} diff --git a/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.good.js b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.good.js new file mode 100644 index 0000000..d6815a8 --- /dev/null +++ b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.good.js @@ -0,0 +1,74 @@ +const pendingChunks = []; +let callInProgress = false; +let transferHalted = false; + +function sendRows(rows, maxArgumentsBytes) { + if (transferHalted) + throw new Error("Retry or discard the failed chunk before sending more rows."); + + const encoder = new TextEncoder(); + const chunks = []; + let chunk = []; + const argumentBytes = (payload) => + encoder.encode(JSON.stringify([payload])).length; + + for (const row of rows) { + if (argumentBytes(JSON.stringify([row])) > maxArgumentsBytes) + throw new Error("A row exceeds the configured payload limit."); + + const candidate = JSON.stringify([...chunk, row]); + + if (argumentBytes(candidate) <= maxArgumentsBytes) { + chunk.push(row); + continue; + } + + chunks.push(JSON.stringify(chunk)); + chunk = [row]; + } + + if (chunk.length > 0) + chunks.push(JSON.stringify(chunk)); + + pendingChunks.push(...chunks); + sendNextChunk(); +} + +function sendNextChunk() { + if (callInProgress || pendingChunks.length === 0) + return; + + callInProgress = true; + const payload = pendingChunks[0]; + Microsoft.Dynamics.NAV.InvokeExtensibilityMethod( + "StoreRows", + [payload], + false, + () => { + pendingChunks.shift(); + callInProgress = false; + sendNextChunk(); + }, + () => { + callInProgress = false; + transferHalted = true; + showTransferError(); + }); +} + +function retryFailedChunk() { + if (!transferHalted) + return; + + transferHalted = false; + sendNextChunk(); +} + +function discardFailedChunk() { + if (!transferHalted) + return; + + pendingChunks.shift(); + transferHalted = false; + sendNextChunk(); +} diff --git a/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.md b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.md new file mode 100644 index 0000000..987eedb --- /dev/null +++ b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.md @@ -0,0 +1,30 @@ +--- +bc-version: [20..] +domain: ui +keywords: [control-add-in, invokeextensibilitymethod, success-callback, throttling, payload, reduced-functionality] +technologies: [javascript] +countries: [w1] +application-area: [all] +--- + +# Serialize control add-in AL calls and keep payloads small + +## Description + +`InvokeExtensibilityMethod` crosses from a control add-in into the Business Central service. Repeated calls that outpace AL execution fill the communication channel, trigger reduced-functionality warnings, and can be queued, throttled, or rejected; an oversized single payload can also be rejected immediately. The success and error callbacks exist so the add-in can bound this traffic. + +## Best Practice + +Send byte-bounded chunks and invoke the next AL event only from the previous call's completion callback. Handle the error callback and stop until the caller explicitly retries or discards the failed chunk. There is no universal safe threshold, so measure the serialized argument array, reserve transport headroom below the server's `ClientServicesMaxUploadSize`, and reject an individual item that exceeds the configured budget. + +See sample: `control-addin-throttle-al-calls-and-payload-size.good.js`. + +## Anti Pattern + +Calling `InvokeExtensibilityMethod` on an interval without tracking completion, recursively creating intervals, or serializing an entire unbounded dataset into one call. These patterns can overwhelm the client-service channel or exceed the upload limit. + +See sample: `control-addin-throttle-al-calls-and-payload-size.bad.js`. + +## Source + +[Control add-in performance best practices](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-control-addin-bestpractices), [InvokeExtensibilityMethod](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods/devenv-invokeextensibility-method), and [control add-in resiliency](https://learn.microsoft.com/dynamics365/business-central/across-controladdin-resiliency). diff --git a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.bad.al b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.bad.al new file mode 100644 index 0000000..a629672 --- /dev/null +++ b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.bad.al @@ -0,0 +1,80 @@ +page 50353 "WS Order API Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'sales'; + APIVersion = 'v1.0'; + EntityName = 'order'; + EntitySetName = 'orders'; + ODataKeyFields = SystemId; + SourceTable = "Sales Header"; + + layout + { + area(content) + { + repeater(records) + { + part(lines; "WS Order Line API Bad") + { + EntityName = 'orderLine'; + EntitySetName = 'orderLines'; + Multiplicity = ZeroOrOne; + SubPageLink = "Order No." = Field("No."); + } + } + } + } +} + +table 50353 "WS Order Line Bad" +{ + fields + { + field(1; "Entry No."; Integer) + { + AutoIncrement = true; + } + field(2; "Order No."; Code[20]) + { + TableRelation = "Sales Header"."No."; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } +} + +page 50354 "WS Order Line API Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'sales'; + APIVersion = 'v1.0'; + EntityName = 'orderLine'; + EntitySetName = 'orderLines'; + ODataKeyFields = SystemId; + SourceTable = "WS Order Line Bad"; + + layout + { + area(content) + { + repeater(records) + { + field(id; Rec.SystemId) + { + Editable = false; + } + field(orderNumber; Rec."Order No.") + { + } + } + } + } +} diff --git a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.good.al b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.good.al new file mode 100644 index 0000000..4b7482a --- /dev/null +++ b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.good.al @@ -0,0 +1,151 @@ +page 50350 "WS Order API" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'sales'; + APIVersion = 'v1.0'; + EntityName = 'order'; + EntitySetName = 'orders'; + ODataKeyFields = SystemId; + SourceTable = "Sales Header"; + + layout + { + area(content) + { + repeater(records) + { + field(id; Rec.SystemId) + { + Editable = false; + } + part(lines; "WS Order Line API") + { + EntityName = 'orderLine'; + EntitySetName = 'orderLines'; + SubPageLink = "Order Id" = Field(SystemId); + } + part(summary; "WS Order Summary API") + { + EntityName = 'orderSummary'; + Multiplicity = ZeroOrOne; + SubPageLink = "Order Id" = Field(SystemId); + } + } + } + } +} + +table 50350 "WS Order Line" +{ + fields + { + field(1; "Entry No."; Integer) + { + AutoIncrement = true; + } + field(2; "Order Id"; Guid) + { + TableRelation = "Sales Header".SystemId; + } + field(3; Description; Text[100]) + { + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } +} + +table 50351 "WS Order Summary" +{ + fields + { + field(1; "Order Id"; Guid) + { + TableRelation = "Sales Header".SystemId; + } + field(2; Summary; Text[100]) + { + } + } + + keys + { + key(PK; "Order Id") + { + Clustered = true; + } + } +} + +page 50351 "WS Order Line API" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'sales'; + APIVersion = 'v1.0'; + EntityName = 'orderLine'; + EntitySetName = 'orderLines'; + ODataKeyFields = SystemId; + SourceTable = "WS Order Line"; + DelayedInsert = true; + + layout + { + area(content) + { + repeater(records) + { + field(id; Rec.SystemId) + { + Editable = false; + } + field(orderId; Rec."Order Id") + { + } + field(description; Rec.Description) + { + } + } + } + } +} + +page 50352 "WS Order Summary API" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'sales'; + APIVersion = 'v1.0'; + EntityName = 'orderSummary'; + EntitySetName = 'orderSummaries'; + ODataKeyFields = SystemId; + SourceTable = "WS Order Summary"; + DelayedInsert = true; + + layout + { + area(content) + { + repeater(records) + { + field(id; Rec.SystemId) + { + Editable = false; + } + field(orderId; Rec."Order Id") + { + } + field(summary; Rec.Summary) + { + } + } + } + } +} diff --git a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md new file mode 100644 index 0000000..4cf81d6 --- /dev/null +++ b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md @@ -0,0 +1,30 @@ +--- +bc-version: [17..] +domain: web-services +keywords: [api-page, page-part, subpagelink, systemid, multiplicity, deep-insert, navigation-property] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Link API parts on SystemId and choose the correct multiplicity + +## Description + +`Multiplicity` is available from runtime 6.3 (Business Central 17.3) and defaults an API page part to a 1:N collection. The multiplicity-specific guidance therefore does not apply to BC 17.0 through 17.2. An API page part creates an OData navigation property and, for collection multiplicity, enables deep insert of child entities. When a custom parent API is keyed by its immutable `SystemId`, its child should carry a related GUID foreign key so the navigation constraint uses that same stable external identity. `Multiplicity` controls whether metadata exposes an object (`ZeroOrOne`) or a collection (`Many`). + +## Best Practice + +Define the child foreign key as `Guid` with a `TableRelation` to the parent table's `SystemId`, then use `SubPageLink = "" = Field(SystemId)` on the parent API page. A child collection may omit `Multiplicity` and rely on the default 1:N relationship, or declare `Multiplicity = Many` explicitly. Set `Multiplicity = ZeroOrOne` when the intended navigation metadata is a singleton. + +See sample: `link-api-parts-on-systemid-and-set-multiplicity.good.al`. + +## Anti Pattern + +On a parent API with `ODataKeyFields = SystemId`, linking a child business field such as `"Order No."` to the parent's `"No."` creates a second identity scheme for navigation instead of using the contract's stable GUID. A separate defect is an explicit `Multiplicity` that conflicts with the intended shape, such as `ZeroOrOne` on an order-lines collection or `Many` on a singleton. Do not treat omission alone as a defect: it is valid for a collection because the default is 1:N, while an intended singleton must explicitly use `Multiplicity = ZeroOrOne`. + +See sample: `link-api-parts-on-systemid-and-set-multiplicity.bad.al`. + +## Source + +[Developing a custom API](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-develop-custom-api) and [Multiplicity property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-multiplicity-property). diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.al b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.al new file mode 100644 index 0000000..9baaa71 --- /dev/null +++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.al @@ -0,0 +1,22 @@ +query 50355 "WS Webhook Customer Query" +{ + QueryType = API; + APIPublisher = 'contoso'; + APIGroup = 'sales'; + APIVersion = 'v1.0'; + EntityName = 'webhookCustomer'; + EntitySetName = 'webhookCustomers'; + + elements + { + dataitem(customer; Customer) + { + column(id; SystemId) + { + } + column(displayName; Name) + { + } + } + } +} diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.js b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.js new file mode 100644 index 0000000..1f624f0 --- /dev/null +++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.js @@ -0,0 +1,4 @@ +function receiveBusinessCentralWebhook(request, response) { + processNotifications(request.body.value); + response.sendStatus(200); +} diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.al b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.al new file mode 100644 index 0000000..0f673cb --- /dev/null +++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.al @@ -0,0 +1,28 @@ +page 50354 "WS Webhook Customer API" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'sales'; + APIVersion = 'v1.0'; + EntityName = 'webhookCustomer'; + EntitySetName = 'webhookCustomers'; + ODataKeyFields = SystemId; + SourceTable = Customer; + + layout + { + area(content) + { + repeater(records) + { + field(id; Rec.SystemId) + { + Editable = false; + } + field(displayName; Rec.Name) + { + } + } + } + } +} diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.js b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.js new file mode 100644 index 0000000..f2e42fd --- /dev/null +++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.js @@ -0,0 +1,11 @@ +function receiveBusinessCentralWebhook(request, response) { + const validationToken = request.query.validationToken; + + if (typeof validationToken === "string") { + response.status(200).type("text/plain").send(validationToken); + return; + } + + processNotifications(request.body.value); + response.sendStatus(200); +} diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md new file mode 100644 index 0000000..b35a05c --- /dev/null +++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: web-services +keywords: [webhook, subscription, validationtoken, expirationdatetime, webhook-supported-resources, api-page, sourcetabletemporary, querytype] +technologies: [al, javascript] +countries: [w1] +application-area: [all] +--- + +# Verify webhook eligibility and complete every validationToken handshake + +## Description + +Business Central can subscribe only to eligible API pages, not every endpoint that can be read through an API. Webhooks exclude API queries, temporary API pages, pages with composite OData keys, pages over system tables, and pages over Job Queue Entry (table 472); the environment's `webhookSupportedResources` endpoint is authoritative. Creating and renewing a subscription both call the `notificationUrl` with `validationToken`, and both fail unless the subscriber returns that token in the response body with `200 OK`. + +## Best Practice + +Before creating a subscription, confirm the resource appears in `webhookSupportedResources` and that a custom endpoint is an API page with a single stable key over an eligible persistent table. Use one validation path that echoes `validationToken` for both create (`POST`) and renew (`PATCH`) handshakes. Track `expirationDateTime` and renew before expiry: online subscriptions expire after three days, while on-premises lifetime defaults to three days and can be changed with `ApiSubscriptionExpiration`. + +See samples: `webhook-eligibility-and-validationtoken-renewal.good.al` and `webhook-eligibility-and-validationtoken-renewal.good.js`. + +## Anti Pattern + +Attempting to subscribe to an API query, temporary/composite/system-table/Job Queue Entry API page, or assuming a successful create handshake makes renewal automatic. Composite includes an explicit multi-field `ODataKeyFields` and a missing `ODataKeyFields` when the source table's primary key has multiple fields. A renewal issues the same validation challenge; a notification handler that ignores the query-string token cannot create or renew the subscription. + +See samples: `webhook-eligibility-and-validationtoken-renewal.bad.al` and `webhook-eligibility-and-validationtoken-renewal.bad.js`. + +## Source + +[Working with webhooks](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/api-reference/v2.0/dynamics-subscriptions) and [Update subscriptions](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/api-reference/v2.0/api/dynamics_subscriptions_update). diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index 2f99c12..e3ada1b 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -16,7 +16,7 @@ application-area: [all] 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 — 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. +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 implement Business Central control add-ins, including their client-service communication. The skill returns `not-applicable` when the diff contains no page or control add-in 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. @@ -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. -- **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). +- **UI-file filter.** UI review applies to files declaring `page`, `pageextension`, or `pagecustomization`, and to JavaScript/CSS/HTML that implements a control add-in's rendering or Business Central communication. 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 files, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, `ShowCaption`, `InstructionalText`, `GridLayout`, `Style`, `StyleExpr`, action definitions, field-level properties, DOM creation, ARIA attributes, keyboard/focus handlers, packaged-resource AJAX, and calls from JavaScript into AL. +- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `control-add-in`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `packaged-resource`, `ajax`, `$.get`, `$.ajax`, `XMLHttpRequest`, `xhrFields`, `withCredentials`, `withcredentials`, `InvokeExtensibilityMethod`, `invokeextensibilitymethod`, `skipIfBusy`, `successCallback`, `success-callback`, `errorCallback`, `setInterval`, `JSON.stringify`, `payload`, `throttling`, `reduced-functionality`, `ClientServicesMaxUploadSize`, `&`, `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 (derived from the index entry's `path`, `title`, and `description`) matches a changed page element. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. @@ -53,6 +53,8 @@ When the post-conflict worklist is empty because no applicable UI knowledge exis 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. +For packaged-resource requests, flag `$.get` or AJAX/XHR that omits `withCredentials` only when the URL is identifiable as a resource in the control add-in package; do not generalize the rule to external endpoints. For `InvokeExtensibilityMethod`, flag repeated or timer-driven calls that can overlap because they do not wait for the success/error callbacks, and unbounded serialized payloads sent in one call. Prefer bounded chunks serialized through completion callbacks. Do not emit generic browser or JavaScript performance advice. + 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). @@ -69,7 +71,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, pagecustomization, or control add-in UI files. +- `not-applicable` — the diff contains no page, pageextension, pagecustomization, or control add-in implementation files. - `partial` — a budget was hit before the worklist was exhausted. - `failed` — an unrecoverable error occurred. diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md index f2e6e6a..1136a4d 100644 --- a/microsoft/skills/review/al-web-services-review.md +++ b/microsoft/skills/review/al-web-services-review.md @@ -3,11 +3,11 @@ kind: action-skill id: al-web-services-review version: 1 title: AL web services review -description: Reviews AL source changes against web-services (API page) guidance from BCQuality. +description: Reviews AL API surfaces and webhook integration handlers against web-services guidance from BCQuality. inputs: [pr-diff, file-path] outputs: [findings-report] bc-version: [all] -technologies: [al] +technologies: [al, javascript] countries: [w1] application-area: [all] --- @@ -27,7 +27,7 @@ Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality 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]`. +- `technologies` — `[al]` or `[javascript]`. - `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`. @@ -37,9 +37,10 @@ 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 page objects declared with `PageType = API`, and any procedure on such a page that exposes a bound action. -- The changed properties and triggers, weighted toward API page metadata (`APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SourceTable`), CRUD guards (`InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`), the `OnOpenPage` trigger, and `OnValidate` triggers on exposed fields. -- Tokens extracted from the diff that relate to API surface and behaviour (`PageType`, `API`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SystemId`, `ServiceEnabled`, `WebServiceActionContext`, `SetActionResponse`, `ReadIsolation`, `IsolationLevel`, `ReadCommitted`, `InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`, `SourceTable`). +- The changed AL object names and types — especially pages declared with `PageType = API`, API page `part` controls, queries declared with `QueryType = API`, and procedures that expose bound actions. +- The changed properties and triggers, weighted toward API page metadata (`APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SourceTable`, `SourceTableTemporary`), navigation metadata (`SubPageLink`, `Multiplicity`, and visible singleton or collection semantics), CRUD guards (`InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`), the `OnOpenPage` trigger, and `OnValidate` triggers on exposed fields. +- Webhook subscriber handlers and subscription lifecycle code, especially code that creates or renews subscriptions, handles `validationToken`, schedules from `expirationDateTime`, or targets resources whose eligibility is visible in the diff. +- Tokens extracted from the diff that relate to API surface and behaviour (`PageType`, `QueryType`, `API`, `api-page`, `page-part`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SystemId`, `SubPageLink`, `subpagelink`, `Multiplicity`, `multiplicity`, `Many`, `ZeroOrOne`, `SourceTableTemporary`, `Job Queue Entry`, `webhook`, `webhookSupportedResources`, `webhook-supported-resources`, `subscriptions`, `notificationUrl`, `validationToken`, `validationtoken`, `expirationDateTime`, `expirationdatetime`, `ServiceEnabled`, `WebServiceActionContext`, `SetActionResponse`, `ReadIsolation`, `IsolationLevel`, `ReadCommitted`, `InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`, `SourceTable`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. @@ -55,6 +56,8 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - 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`. +For API parts whose parent declares `ODataKeyFields = SystemId`, detect a child foreign key linked to a parent business field instead of `Field(SystemId)`. Do not apply the SystemId-link rule to APIs intentionally keyed by another field. Omitted `Multiplicity` is valid and means the documented default 1:N collection; never report omission alone. Report an explicit `ZeroOrOne` only when the visible contract clearly intends a collection or deep insert, and report an explicit `Many` only when it clearly intends a singleton. Singleton metadata requires an explicit `ZeroOrOne`; do not infer singleton intent from naming alone. For webhook eligibility, detect `QueryType = API`, `SourceTableTemporary = true`, composite `ODataKeyFields` (including an omitted property when a visible source primary key is composite), Job Queue Entry, and visible system-table sources; do not infer an unknown table number. For lifecycle code, require both create and renew paths to use a handler that returns the query-string `validationToken` verbatim with `200 OK`, and flag renewal scheduling that assumes subscriptions are permanent instead of using `expirationDateTime`. Do not emit generic HTTP or REST advice. + Set `confidence` to: - `high` when the detection is based on an unambiguous pattern match (identifier, syntax, object type). @@ -71,7 +74,7 @@ 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 web-services 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). +- `not-applicable` — the task context contains no AL API surface, JavaScript webhook subscription lifecycle code, or JavaScript notification handler, or the `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. From be1b92b624679f8c031061602e7d3a3b5f71a688 Mon Sep 17 00:00:00 2001 From: Wenjie Fan <31087545+gggdttt@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:59:25 +0200 Subject: [PATCH 23/86] style-review: fix example finding id to match references[0].path (#101) The al-style-review worked example set findings[0].id to a non-existent knowledge file (apply-approved-label-suffixes.md) while references[0].path points to the real file (label-suffix-approved-list.md). The DO output contract requires id to equal references[0].path for citation-based findings. Align id to the existing file. Co-authored-by: wenjiefan --- microsoft/skills/review/al-style-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index 4c00c45..74dac1f 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -89,7 +89,7 @@ Output conforms to the DO output contract. A populated example: }, "findings": [ { - "id": "microsoft/knowledge/style/apply-approved-label-suffixes.md", + "id": "microsoft/knowledge/style/label-suffix-approved-list.md", "severity": "info", "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": { From 3d29c172a9ad4f4c714322ae515208e542dc703e Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 14 Jul 2026 14:20:27 +0200 Subject: [PATCH 24/86] Promote validated community knowledge (#105) Move eight net-new rules into the Microsoft layer, remove six overlapping articles, and update review skill discovery and references. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0b130227-d418-4bc0-9e7d-ec6a37adf039 Co-authored-by: Jesper Schulz-Wedde --- ...t-subscriber-rolls-back-whole-batch.bad.al | 13 ------- ...-subscriber-rolls-back-whole-batch.good.al | 33 ---------------- ...event-subscriber-rolls-back-whole-batch.md | 24 ------------ ...ll-skips-ondelete-unless-runtrigger.bad.al | 11 ------ ...l-skips-ondelete-unless-runtrigger.good.al | 16 -------- ...eteall-skips-ondelete-unless-runtrigger.md | 24 ------------ ...ds-unlisted-field-triggers-jit-load.bad.al | 26 ------------- ...s-unlisted-field-triggers-jit-load.good.al | 24 ------------ ...fields-unlisted-field-triggers-jit-load.md | 24 ------------ ...elemetryscope-to-extensionpublisher.bad.al | 17 --------- ...lemetryscope-to-extensionpublisher.good.al | 15 -------- ...lt-telemetryscope-to-extensionpublisher.md | 24 ------------ .../ui/split-button-standard-groups.md | 20 ---------- .../upgrade/no-series-bc24-migration.md | 20 ---------- ...eep-copilot-help-url-to-two-path-levels.md | 4 +- .../fielderror-default-message-logic.bad.al | 2 +- .../fielderror-default-message-logic.good.al | 5 +-- .../fielderror-default-message-logic.md | 36 +++++++++--------- .../fielderror-vs-testfield.bad.al | 2 +- .../fielderror-vs-testfield.good.al | 4 +- .../error-handling/fielderror-vs-testfield.md | 38 +++++++++---------- .../security/secrets-isolated-storage.bad.al | 0 .../security/secrets-isolated-storage.good.al | 2 +- .../security/secrets-isolated-storage.md | 4 +- .../knowledge/ui/fasttab-field-importance.md | 38 +++++++++---------- .../knowledge/ui/page-background-tasks.md | 38 +++++++++---------- ...r-actionref-syntax-for-promoted-actions.md | 38 +++++++++---------- .../knowledge/ui/promoted-action-groups.md | 38 +++++++++---------- .../skills/review/al-appsource-review.md | 2 +- microsoft/skills/review/al-code-review.md | 20 +++++----- .../skills/review/al-error-handling-review.md | 2 +- .../skills/review/al-performance-review.md | 6 +-- microsoft/skills/review/al-security-review.md | 4 +- .../skills/review/al-telemetry-review.md | 36 +----------------- microsoft/skills/review/al-ui-review.md | 4 +- 35 files changed, 136 insertions(+), 478 deletions(-) delete mode 100644 community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.bad.al delete mode 100644 community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.good.al delete mode 100644 community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.md delete mode 100644 community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.bad.al delete mode 100644 community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.good.al delete mode 100644 community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.md delete mode 100644 community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.bad.al delete mode 100644 community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.good.al delete mode 100644 community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md delete mode 100644 community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.bad.al delete mode 100644 community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.good.al delete mode 100644 community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.md delete mode 100644 community/knowledge/ui/split-button-standard-groups.md delete mode 100644 community/knowledge/upgrade/no-series-bc24-migration.md rename {community => microsoft}/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md (94%) rename {community => microsoft}/knowledge/error-handling/fielderror-default-message-logic.bad.al (90%) rename {community => microsoft}/knowledge/error-handling/fielderror-default-message-logic.good.al (74%) rename {community => microsoft}/knowledge/error-handling/fielderror-default-message-logic.md (96%) rename {community => microsoft}/knowledge/error-handling/fielderror-vs-testfield.bad.al (89%) rename {community => microsoft}/knowledge/error-handling/fielderror-vs-testfield.good.al (82%) rename {community => microsoft}/knowledge/error-handling/fielderror-vs-testfield.md (95%) rename {community => microsoft}/knowledge/security/secrets-isolated-storage.bad.al (100%) rename {community => microsoft}/knowledge/security/secrets-isolated-storage.good.al (84%) rename {community => microsoft}/knowledge/security/secrets-isolated-storage.md (70%) rename {community => microsoft}/knowledge/ui/fasttab-field-importance.md (63%) rename {community => microsoft}/knowledge/ui/page-background-tasks.md (96%) rename {community => microsoft}/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md (95%) rename {community => microsoft}/knowledge/ui/promoted-action-groups.md (71%) diff --git a/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.bad.al b/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.bad.al deleted file mode 100644 index c040ffc..0000000 --- a/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50124 "Sales Line Guard Bad Sample" -{ - // A throw here executes synchronously inside the transaction of the write - // that fired the event. With no per-record savepoint, it rolls back ALL - // uncommitted work since the last COMMIT — the entire batch, not just this - // line. One bad row discards every row imported before it. - [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterInsertEvent', '', false, false)] - local procedure OnAfterInsertSalesLine(var Rec: Record "Sales Line") - begin - if Rec.Quantity <= 0 then - Rec.FieldError(Quantity, 'must be greater than zero'); - end; -} diff --git a/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.good.al b/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.good.al deleted file mode 100644 index 6e67e53..0000000 --- a/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.good.al +++ /dev/null @@ -1,33 +0,0 @@ -codeunit 50124 "Batch Import Good Sample" -{ - procedure ImportAll(var StagingLine: Record "Sales Line") - var - FailedCount: Integer; - begin - if StagingLine.FindSet() then - repeat - // Isolate each record behind a Codeunit.Run boundary: a failure - // inside the run rolls back only that record's work, and the - // batch continues instead of discarding everything. - if not Codeunit.Run(Codeunit::"Batch Import One Line", StagingLine) then - FailedCount += 1; - until StagingLine.Next() = 0; - - if FailedCount > 0 then - Message('%1 line(s) were skipped; the rest were imported.', FailedCount); - end; -} - -codeunit 50125 "Batch Import One Line" -{ - TableNo = "Sales Line"; - - trigger OnRun() - begin - // Validation lives here. If it throws, only this line rolls back, - // because the caller wrapped the call in Codeunit.Run. - Rec.TestField("No."); - Rec.TestField(Quantity); - Rec.Insert(true); - end; -} diff --git a/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.md b/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.md deleted file mode 100644 index 72e7736..0000000 --- a/community/knowledge/error-handling/table-event-subscriber-rolls-back-whole-batch.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -bc-version: [all] -domain: error-handling -keywords: [table-events, oninsert, onmodify, ondelete, transaction, rollback, commit, batch, subscriber] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# A throw in a table-event subscriber rolls back the whole batch - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Table-trigger event subscribers (`OnAfterInsertEvent`, `OnAfterModifyEvent`, `OnAfterDeleteEvent`, and their `OnBefore` counterparts) execute synchronously inside the transaction of the write that fired them. Because AL runs on a single implicit transaction with no per-record savepoint, an error raised in such a subscriber rolls back **all work since the last `COMMIT`** — not just the record that triggered it. In a batch loop with no intermediate `COMMIT`s, a single failing record discards the entire batch. The intuition that subscriber validation fails only the current record is wrong on the BC platform. - -## Best Practice - -Decide the failure granularity deliberately. If a batch must continue past individual failures, do not throw from the table-event subscriber — collect the error (for example via `ErrorInfo`/collectible errors) and let the loop continue, or isolate each record's work behind a `Codeunit.Run` / `if Codeunit.Run() then` boundary so its failure rolls back only that record. Insert intermediate `COMMIT`s only with full awareness of the durability trade-off. - -## Anti Pattern - -Putting `Error`/`TestField`/`FieldError` validation inside a table-event subscriber and assuming it rejects just the offending record during bulk processing. The first failure unwinds every uncommitted record in the run, turning a one-row data problem into a whole-batch rollback. diff --git a/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.bad.al b/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.bad.al deleted file mode 100644 index f5895cd..0000000 --- a/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50130 "Purge Orders Bad Sample" -{ - procedure PurgeCancelledLines(var SalesLine: Record "Sales Line") - begin - // Assumes DeleteAll fires OnDelete and cascades to reservation entries - // and item applications. It does not: parameterless DeleteAll() is - // DeleteAll(false) and skips OnDelete, so the rows vanish but their - // dependent records are orphaned. - SalesLine.DeleteAll(); - end; -} diff --git a/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.good.al b/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.good.al deleted file mode 100644 index 7d83c7c..0000000 --- a/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.good.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 50130 "Purge Orders Good Sample" -{ - procedure PurgeCancelledLines(var SalesLine: Record "Sales Line") - begin - // These lines have OnDelete cleanup (reservation entries, item - // application). Pass true so DeleteAll runs OnDelete per record and the - // cleanup actually happens — the row-by-row cost is accepted on purpose. - SalesLine.DeleteAll(true); - end; - - procedure PurgeStagingBuffer(var TempBuffer: Record "Name/Value Buffer" temporary) - begin - // No OnDelete logic to run: the fast, set-based form is correct here. - TempBuffer.DeleteAll(); - end; -} diff --git a/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.md b/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.md deleted file mode 100644 index 48d630c..0000000 --- a/community/knowledge/performance/deleteall-skips-ondelete-unless-runtrigger.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [deleteall, ondelete, run-trigger, set-based-delete, bulk-delete, triggers, validation] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# DeleteAll skips OnDelete unless you pass RunTrigger - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -`Record.DeleteAll()` — equivalently `DeleteAll(false)` — translates to a single set-based SQL `DELETE` and **does not** run AL `OnDelete` triggers or field/table validations. Only database-level referential constraints still apply. To run `OnDelete` logic you must call `DeleteAll(true)`, which then deletes record-by-record and forfeits the set-based performance, making it equivalent to a `FindSet` loop calling `Delete(true)`. The common misconception, which training data reproduces, is that `DeleteAll` iterates and fires `OnDelete` per record; it does not. (Parameterless `Delete()` likewise defaults to `Delete(false)` and skips `OnDelete`.) - -## Best Practice - -Use `DeleteAll()` / `DeleteAll(false)` for bulk deletion only when no AL `OnDelete` cleanup is required — it is the fast, set-based form. When `OnDelete` logic must run (cascading deletes, ledger cleanup, integration events), pass `DeleteAll(true)` and accept the row-by-row cost, or refactor the cleanup to run explicitly before the bulk delete. - -## Anti Pattern - -Calling `DeleteAll()` and assuming dependent records, integration events, or validation side effects are handled by `OnDelete`. The deletion succeeds but the AL-side cleanup never runs, leaving orphaned data — and adding a manual `FindSet`/`Delete` loop "for safety" reintroduces the per-record cost the set-based form was chosen to avoid. diff --git a/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.bad.al b/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.bad.al deleted file mode 100644 index a5c3036..0000000 --- a/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.bad.al +++ /dev/null @@ -1,26 +0,0 @@ -codeunit 50132 "LoadFields Bad Sample" -{ - procedure TotalReleasedAmount(): Decimal - var - SalesHeader: Record "Sales Header"; - Total: Decimal; - begin - // "Currency Code" is not listed. The helper takes SalesHeader BY VALUE, - // so the copy neither shares the load set nor updates the enumerator: - // reading the unlisted field triggers a fresh JIT load (an extra Get) - // on EVERY iteration, quietly reversing the saving. - SalesHeader.SetLoadFields("Amount Including VAT", Status); - if SalesHeader.FindSet() then - repeat - if IsLocalReleased(SalesHeader) then - Total += SalesHeader."Amount Including VAT"; - until SalesHeader.Next() = 0; - exit(Total); - end; - - local procedure IsLocalReleased(SalesHeader: Record "Sales Header"): Boolean - begin - exit((SalesHeader.Status = SalesHeader.Status::Released) and - (SalesHeader."Currency Code" = '')); - end; -} diff --git a/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.good.al b/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.good.al deleted file mode 100644 index 7ab9016..0000000 --- a/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.good.al +++ /dev/null @@ -1,24 +0,0 @@ -codeunit 50132 "LoadFields Good Sample" -{ - procedure TotalReleasedAmount(): Decimal - var - SalesHeader: Record "Sales Header"; - Total: Decimal; - begin - // Every field read anywhere downstream is listed — including the one - // the by-var helper reads — so no JIT load is ever triggered. - SalesHeader.SetLoadFields("Amount Including VAT", Status, "Currency Code"); - if SalesHeader.FindSet() then - repeat - if IsLocalReleased(SalesHeader) then - Total += SalesHeader."Amount Including VAT"; - until SalesHeader.Next() = 0; - exit(Total); - end; - - local procedure IsLocalReleased(var SalesHeader: Record "Sales Header"): Boolean - begin - exit((SalesHeader.Status = SalesHeader.Status::Released) and - (SalesHeader."Currency Code" = '')); - end; -} diff --git a/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md b/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md deleted file mode 100644 index ddce795..0000000 --- a/community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [setloadfields, partial-records, just-in-time-load, jit-load, round-trip, pass-by-value, enumerator] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Reading an unlisted field after SetLoadFields triggers a JIT load - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -`SetLoadFields` loads only the named fields, but the trap is what happens when code later reads a field that was *not* listed: the platform silently issues a **just-in-time (JIT) load** — an implicit `Get` that fetches the missing field(s) in a second database round-trip. A single JIT load can erase the saving; the real danger is a JIT that repeats per record. The optimization is only a win if the listed set covers every field touched anywhere downstream, not just in the immediate code block. - -## Best Practice - -Before adding `SetLoadFields`, audit the *whole* access lifecycle of the record variable — every field read in the loop body, in called procedures, in `OnValidate`/`OnAfterGetRecord`, and in anything that receives the record — and list all of them via `SetLoadFields`/`AddLoadFields`. Be especially careful when passing a partial record **by value**: the copy does not share the load set and its enumerator is not updated, so a helper that reads an unlisted field re-triggers the JIT on *every* iteration. Pass by `var` where you can (a JIT then updates the enumerator, so later iterations don't re-load), or call `AddLoadFields` before passing by value. If you cannot enumerate the fields confidently, prefer not to call `SetLoadFields` at all. See the existing guidance on when partial records pay off (`use-setloadfields-for-partial-records`). - -## Anti Pattern - -Adding `SetLoadFields(Field1, Field2)` at the top of a loop, then reading `Field3` deeper in the body or inside a by-value helper. The code compiles and returns correct data, but pays a hidden JIT round-trip — and in the by-value case it repeats once per row, quietly reversing the gain. JIT loads also introduce `Inconsistent read` / record-modified race errors that a full non-partial load avoids. Reviewer signal: a `SetLoadFields` list that omits a field later read through that record variable, especially a record passed by value to a procedure that reads a field the caller never listed. diff --git a/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.bad.al b/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.bad.al deleted file mode 100644 index 75856a6..0000000 --- a/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.bad.al +++ /dev/null @@ -1,17 +0,0 @@ -codeunit 50136 "Telemetry Bad Sample" -{ - procedure LogSyncDiagnostic(RecordsProcessed: Integer) - var - Dimensions: Dictionary of [Text, Text]; - begin - Dimensions.Add('recordsProcessed', Format(RecordsProcessed)); - - // TelemetryScope::All pushes this internal diagnostic into every - // customer's Application Insights too, inflating their ingestion cost - // and burying their own signals in noise. ExtensionPublisher is the - // correct scope for publisher-only diagnostics. - Session.LogMessage( - 'SYNC001', 'Nightly sync completed.', Verbosity::Normal, - DataClassification::SystemMetadata, TelemetryScope::All, Dimensions); - end; -} diff --git a/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.good.al b/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.good.al deleted file mode 100644 index c0e9e17..0000000 --- a/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.good.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50136 "Telemetry Good Sample" -{ - procedure LogSyncDiagnostic(RecordsProcessed: Integer) - var - Dimensions: Dictionary of [Text, Text]; - begin - Dimensions.Add('recordsProcessed', Format(RecordsProcessed)); - - // A diagnostic only the publisher acts on: route it to the publisher's - // own Application Insights, not the customer's environment resource. - Session.LogMessage( - 'SYNC001', 'Nightly sync completed.', Verbosity::Normal, - DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, Dimensions); - end; -} diff --git a/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.md b/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.md deleted file mode 100644 index 8b41662..0000000 --- a/community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -bc-version: [all] -domain: telemetry -keywords: [telemetry, session-logmessage, telemetryscope, application-insights, extensionpublisher, ingestion-cost] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Default TelemetryScope to ExtensionPublisher, not All - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -The `TelemetryScope` parameter of `Session.LogMessage` (and `LogError`) controls *where* a custom telemetry signal is routed, not just whether it is emitted. `TelemetryScope::ExtensionPublisher` sends the signal only to the extension publisher's own Application Insights resource. `TelemetryScope::All` sends it to **both** the publisher's resource **and** the customer's environment-level Application Insights resource. The distinction is easy to get wrong because both values compile and both "emit telemetry" — but `All` silently adds to the customer's ingestion volume and cost. - -## Best Practice - -Default to `TelemetryScope::ExtensionPublisher` for diagnostic telemetry that only the publisher acts on. Reserve `TelemetryScope::All` for signals the customer's own administrators are expected to monitor and act on (for example, a business event surfaced to their environment telemetry). Treat the choice as a deliberate routing decision per signal, not a copy-paste default. - -## Anti Pattern - -Emitting all custom telemetry with `TelemetryScope::All` "to be safe." This pushes the publisher's internal diagnostics into every customer's Application Insights, inflating their ingestion cost and burying their own signals in noise — a footgun a code reviewer can catch by flagging `All` on any signal the customer would not act on. diff --git a/community/knowledge/ui/split-button-standard-groups.md b/community/knowledge/ui/split-button-standard-groups.md deleted file mode 100644 index eeac8f2..0000000 --- a/community/knowledge/ui/split-button-standard-groups.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -bc-version: [21..] -domain: ui -keywords: [showas, splitbutton, promoted-actions, actionref, posting-actions, release-action, action-bar] -technologies: [al] -countries: [w1] -application-area: [all] ---- -# Reserve `ShowAs = SplitButton` For Standard Posting And Release Groups - -> Contributions welcome — open a PR to refine or extend this article. - -## Description -Setting `ShowAs = SplitButton` on a `group` inside `area(Promoted)` renders a primary one-click button with a dropdown of related alternatives, where the FIRST `actionref` in the group becomes the primary (left) button. Business Central users have learned this pattern from the two standard groups it ships with — Posting (`Post`, `Post and Print`, `Post and Send`, `Preview Posting`) and Release (`Release`, `Reopen`). Inventing new split-button groups for unrelated actions, or ordering the dropdown so the most common action is not first, breaks that learned muscle memory and makes users guess what the left button will do. - -## Best Practice -Use `ShowAs = SplitButton` only when all hold: the actions are genuinely variations of one operation, there is an obvious most-frequent primary, and the dropdown stays at roughly two to four items. Place that primary action as the first `actionref` so it occupies the left button; order the remaining refs by descending frequency. Outside the Posting and Release conventions, treat a new split-button group as something to justify, not a default — a plain promoted group or category is usually the safer choice and keeps the action bar predictable. - -## Anti Pattern -Grouping unrelated actions under one split button to save toolbar space — for example pairing `Post` with `Delete`, or `Release` with `Print` — so the left button performs whatever happens to be listed first. The reviewer signal is a group with `ShowAs = SplitButton` whose member `actionref`s do not share a verb or workflow, a primary that is not the most common action, or a dropdown padded well beyond four items. Each makes the immediate left-click unpredictable and costs the user the very click the split button was meant to save. diff --git a/community/knowledge/upgrade/no-series-bc24-migration.md b/community/knowledge/upgrade/no-series-bc24-migration.md deleted file mode 100644 index d0500c9..0000000 --- a/community/knowledge/upgrade/no-series-bc24-migration.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -bc-version: [24..] -domain: upgrade -keywords: [no-series, noseriesmanagement, codeunit-310, getnextno, peeknextno, testmanual, arerelated, no-series-batch, business-foundation, obsolete-codeunit] -technologies: [al] -countries: [w1] -application-area: [all] ---- -# Migrate No. Series Calls From NoSeriesManagement To The BC24 No. Series Module - -> Contributions welcome — open a PR to refine or extend this article. - -## Description -In BC24 (2024 Wave 1) Microsoft moved number generation into the Business Foundation `No. Series` codeunit (310) and obsoleted the legacy `NoSeriesManagement` codeunit (396). Code that still declares `Codeunit NoSeriesManagement` or calls its methods compiles only against the temporary obsolete shim and will break once Microsoft removes it. The new API is not a drop-in rename: the facade exposes a small, specific set of real methods, parameter shapes changed, and the old single method that both previewed and consumed a number was split into two. Getting the mapping wrong silently consumes numbers when you only meant to preview, leaving gaps in the sequence. - -## Best Practice -Replace the `NoSeriesManagement` variable with `Codeunit "No. Series"` and map each call deliberately using the facade's actual methods — `GetNextNo`, `PeekNextNo`, `GetLastNoUsed`, `TestManual`, `IsManual`, and `AreRelated`. Use `GetNextNo(SeriesCode, RefDate)` only when you intend to consume and advance the series for a committed document, and `PeekNextNo(SeriesCode, RefDate)` for any display, validation, or preview-posting path where you must not consume. Replace `InitSeries` with a guarded `if "No." = '' then "No." := NoSeries.GetNextNo(...)`. Map `SelectSeries` to `LookupRelatedNoSeries`, relationship checks the old code did by hand to `AreRelated`, and both `TestManual` and `ManualNoAllowed` to `TestManual` (which now raises its own error). For multi-document allocation use `Codeunit "No. Series - Batch"` and persist its state once with `SaveState` instead of committing per iteration. Treat the migration as an opportunity to add preview-posting support, since `PeekNextNo` now makes that trivial. - -## Anti Pattern -Mechanically swapping the codeunit reference while keeping the old boolean call shape. The legacy `GetNextNo(Series, Date, false)` meant "peek" and `GetNextNo(Series, Date, true)` meant "consume"; the new `GetNextNo` always consumes and takes no boolean. Equally common is inventing validation helpers such as `IsValidNo`, `VerifySeriesExists`, `IsValidForDate`, or `TryGetNextNo` — these names are not on the `No. Series` or `No. Series - Batch` codeunits and will not compile, a frequent LLM hallucination for this migration. A reviewer can detect the defect by the residual third boolean argument, by any lingering `NoSeriesMgt`/`NoSeriesManagement` identifier, by a fabricated method name, or by an `OnBeforeGetNextNo`/`OnAfterGetNextNo` subscriber — those events were removed without replacement, so that logic must be rewritten as inline pre/post procedures, not re-subscribed. A subtler signal is `GetNextNo` used merely to display a preview, which silently advances the series and creates number gaps; that should be `PeekNextNo`. diff --git a/community/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md b/microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md similarity index 94% rename from community/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md rename to microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md index fa3dce5..9ceaa3b 100644 --- a/community/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md +++ b/microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md @@ -1,5 +1,5 @@ --- -bc-version: [24..] +bc-version: [27..] domain: appsource keywords: [app-json, help-url, copilot, grounding, documentation, url-depth, contexturl] technologies: [al] @@ -9,8 +9,6 @@ application-area: [all] # Keep the Copilot help URL to two path levels -> Contributions welcome — open a PR to refine or extend this article. - ## Description The `help` URL declared in `app.json` is what Copilot uses to ground answers about your app. That URL may be at most **two path levels** deep (for example `https://contoso.com/docs/myapp`). If you point it at a deeper path (three or more segments), Copilot does not use the URL as given: it truncates to the first two levels, drops any fragments and query strings, and then grounds on **all** content beneath that two-level path. The failure is silent — there is no build error — and the practical effect is worse answers, because Copilot may ingest sibling apps' documentation that lives under the same two-level parent. diff --git a/community/knowledge/error-handling/fielderror-default-message-logic.bad.al b/microsoft/knowledge/error-handling/fielderror-default-message-logic.bad.al similarity index 90% rename from community/knowledge/error-handling/fielderror-default-message-logic.bad.al rename to microsoft/knowledge/error-handling/fielderror-default-message-logic.bad.al index c4a57a2..75d51f5 100644 --- a/community/knowledge/error-handling/fielderror-default-message-logic.bad.al +++ b/microsoft/knowledge/error-handling/fielderror-default-message-logic.bad.al @@ -9,7 +9,7 @@ table 50120 "FieldError Default Bad" procedure ValidateForRelease() begin - // Re-testing a field and handing FieldError a fully-formed sentence. + // This re-tests a field and gives FieldError a fully formed sentence. // The framework already prepends the caption and appends the value, // so this renders as "Currency Code The Currency Code field must have // a value. in ..." — caption repeated, capital letter mid-sentence, diff --git a/community/knowledge/error-handling/fielderror-default-message-logic.good.al b/microsoft/knowledge/error-handling/fielderror-default-message-logic.good.al similarity index 74% rename from community/knowledge/error-handling/fielderror-default-message-logic.good.al rename to microsoft/knowledge/error-handling/fielderror-default-message-logic.good.al index 0007660..b826194 100644 --- a/community/knowledge/error-handling/fielderror-default-message-logic.good.al +++ b/microsoft/knowledge/error-handling/fielderror-default-message-logic.good.al @@ -9,9 +9,8 @@ table 50120 "FieldError Default Good" procedure ValidateForRelease() begin - // Plain required-field gate: TestField checks the condition and raises - // the error in one call, with caption and record context supplied by - // the framework. + // TestField checks this required-field condition and raises the error + // with caption and record context supplied by the framework. TestField("Currency Code"); // Condition already evaluated: pass only a lowercase predicate so it diff --git a/community/knowledge/error-handling/fielderror-default-message-logic.md b/microsoft/knowledge/error-handling/fielderror-default-message-logic.md similarity index 96% rename from community/knowledge/error-handling/fielderror-default-message-logic.md rename to microsoft/knowledge/error-handling/fielderror-default-message-logic.md index b0641dc..578092f 100644 --- a/community/knowledge/error-handling/fielderror-default-message-logic.md +++ b/microsoft/knowledge/error-handling/fielderror-default-message-logic.md @@ -1,20 +1,18 @@ ---- -bc-version: [all] -domain: error-handling -keywords: [fielderror, testfield, error-message, field-caption, lowercase-convention, record-context, validation] -technologies: [al] -countries: [w1] -application-area: [all] ---- -# Rely On FieldError's Auto-Generated Context And Pass Only A Lowercase Predicate - -> Contributions welcome — open a PR to refine or extend this article. - -## Description -`Rec.FieldError(FieldNo)` does not just print the text you give it. Business Central automatically prepends the field caption, appends the current field value (when non-blank), and suffixes the table name and primary-key values for record identification. The optional second argument is only the middle predicate of that sentence — e.g. `"must be unique"`, not a whole self-contained message. Misunderstanding this leads to messages that duplicate the caption and value or read as broken grammar, because the framework's surrounding text is built to join a lowercase fragment. - -## Best Practice -For a plain required-field check, prefer `TestField`, which tests the condition and raises the error in one call. When the condition is non-trivial and has already been evaluated, call `FieldError(FieldNo)` with no message to get the localized default (`must have a value`, `is not valid`, etc.), or pass a short lowercase predicate such as `FieldError(FieldNo, 'must be a positive number')`. Start the custom text with a lowercase letter so it reads as one sentence with the auto-inserted caption, and use a field-number reference (or the field token) rather than a hard-coded field name so captions and translations stay correct. Let the framework supply the caption, value, table, and key context for you. - -## Anti Pattern +--- +bc-version: [all] +domain: error-handling +keywords: [fielderror, testfield, error-message, field-caption, lowercase-convention, record-context, validation] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Rely On FieldError's Auto-Generated Context And Pass Only A Lowercase Predicate + +## Description +`Rec.FieldError(FieldNo)` does not just print the text you give it. Business Central automatically prepends the field caption, appends the current field value (when non-blank), and suffixes the table name and primary-key values for record identification. The optional second argument is only the middle predicate of that sentence — e.g. `"must be unique"`, not a whole self-contained message. Misunderstanding this leads to messages that duplicate the caption and value or read as broken grammar, because the framework's surrounding text is built to join a lowercase fragment. + +## Best Practice +For a plain required-field check, prefer `TestField`, which tests the condition and raises the error in one call. When the condition is non-trivial and has already been evaluated, call `FieldError(FieldNo)` with no message to get the localized default (`must have a value`, `is not valid`, etc.), or pass a short lowercase predicate such as `FieldError(FieldNo, 'must be a positive number')`. Start the custom text with a lowercase letter so it reads as one sentence with the auto-inserted caption, and use a field-number reference (or the field token) rather than a hard-coded field name so captions and translations stay correct. Let the framework supply the caption, value, table, and key context for you. + +## Anti Pattern Re-testing a condition you already evaluated, or passing a fully formed sentence like `'The Amount field must be positive.'` to `FieldError`. The result reads as `Amount The Amount field must be positive. in Gen. Journal Line ...` — capital letter mid-sentence, caption and value repeated, and a stray trailing clause. Reviewer signals: a `FieldError` argument that names the field, restates the current value, starts with a capital letter, or ends with a period. Each is a sign the author treated `FieldError` like `Error` instead of as a predicate slotted into framework-generated context. \ No newline at end of file diff --git a/community/knowledge/error-handling/fielderror-vs-testfield.bad.al b/microsoft/knowledge/error-handling/fielderror-vs-testfield.bad.al similarity index 89% rename from community/knowledge/error-handling/fielderror-vs-testfield.bad.al rename to microsoft/knowledge/error-handling/fielderror-vs-testfield.bad.al index 17d559e..8ccf33b 100644 --- a/community/knowledge/error-handling/fielderror-vs-testfield.bad.al +++ b/microsoft/knowledge/error-handling/fielderror-vs-testfield.bad.al @@ -9,7 +9,7 @@ table 50122 "FieldError vs TestField Bad" procedure PostDocument() begin - // FieldError performs no comparison and always raises the moment it is + // FieldError performs no comparison and raises as soon as it is // reached, so this "check" terminates PostDocument every time — the // Posting Date is never actually tested, and the amount rule below is // dead code. diff --git a/community/knowledge/error-handling/fielderror-vs-testfield.good.al b/microsoft/knowledge/error-handling/fielderror-vs-testfield.good.al similarity index 82% rename from community/knowledge/error-handling/fielderror-vs-testfield.good.al rename to microsoft/knowledge/error-handling/fielderror-vs-testfield.good.al index 43c504e..e39073d 100644 --- a/community/knowledge/error-handling/fielderror-vs-testfield.good.al +++ b/microsoft/knowledge/error-handling/fielderror-vs-testfield.good.al @@ -9,8 +9,8 @@ table 50122 "FieldError vs TestField Good" procedure PostDocument() begin - // Simple presence gate: TestField performs the check itself and raises - // only when the field is empty. Self-documenting prerequisite. + // TestField performs this simple presence check and raises only when + // the field is empty. Self-documenting prerequisite. TestField("Posting Date"); // Business logic has already determined the value is invalid; diff --git a/community/knowledge/error-handling/fielderror-vs-testfield.md b/microsoft/knowledge/error-handling/fielderror-vs-testfield.md similarity index 95% rename from community/knowledge/error-handling/fielderror-vs-testfield.md rename to microsoft/knowledge/error-handling/fielderror-vs-testfield.md index 03117da..2208a18 100644 --- a/community/knowledge/error-handling/fielderror-vs-testfield.md +++ b/microsoft/knowledge/error-handling/fielderror-vs-testfield.md @@ -1,20 +1,18 @@ ---- -bc-version: [all] -domain: error-handling -keywords: [fielderror, testfield, field-validation, onvalidate, error-message, mandatory-field, record-context] -technologies: [al] -countries: [w1] -application-area: [all] ---- -# Choose `TestField` For Conditional Checks And `FieldError` For Already-Failed Validation - -> Contributions welcome — open a PR to refine or extend this article. - -## Description -`TestField` and `FieldError` look interchangeable but behave differently, and choosing the wrong one produces either dead code or a check that never fires. `TestField` performs the comparison itself and throws only when the field is empty or does not match the supplied value; `FieldError` performs no comparison and always raises an error the moment it is reached. Both attach the field caption and the record's primary-key context to the message automatically, which is why neither should be replaced by a hand-built `Error` call that interpolates the field name as a literal. - -## Best Practice -Use `TestField` when the condition is a simple presence-or-equality check on a single field — mandatory-field gates and prerequisite checks at the top of a procedure read clearly and self-document intent. Use `FieldError` inside an `OnValidate` trigger or a validation procedure where surrounding business logic has already determined the value is invalid and you want a specific, custom message. Rely on the built-in field-and-record context both methods add rather than re-stating the field name in the text. - -## Anti Pattern -Calling `FieldError` to "test" a field — placing it on a path that is reached unconditionally and expecting it to validate — terminates execution every time because `FieldError` never evaluates a condition. The inverse smell is reaching for `TestField` when the rule needs a tailored message, then bolting a vague generic string onto a check that cannot express the real business reason. A reviewer can spot the first by a `FieldError` that is not guarded by a preceding `if`, and the second by a `TestField` whose intent comment describes a condition more complex than presence or equality. +--- +bc-version: [all] +domain: error-handling +keywords: [fielderror, testfield, field-validation, onvalidate, error-message, mandatory-field, record-context] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Choose `TestField` For Conditional Checks And `FieldError` For Already-Failed Validation + +## Description +`TestField` and `FieldError` look interchangeable but behave differently, and choosing the wrong one produces either dead code or a check that never fires. `TestField` performs the comparison itself and throws only when the field is empty or does not match the supplied value; `FieldError` performs no comparison and always raises an error the moment it is reached. Both attach the field caption and the record's primary-key context to the message automatically, which is why neither should be replaced by a hand-built `Error` call that interpolates the field name as a literal. + +## Best Practice +Use `TestField` when the condition is a simple presence-or-equality check on a single field — mandatory-field gates and prerequisite checks at the top of a procedure read clearly and self-document intent. Use `FieldError` inside an `OnValidate` trigger or a validation procedure where surrounding business logic has already determined the value is invalid and you want a specific, custom message. Rely on the built-in field-and-record context both methods add rather than re-stating the field name in the text. + +## Anti Pattern +Calling `FieldError` to "test" a field — placing it on a path that is reached unconditionally and expecting it to validate — terminates execution every time because `FieldError` never evaluates a condition. The inverse smell is reaching for `TestField` when the rule needs a tailored message, then bolting a vague generic string onto a check that cannot express the real business reason. A reviewer can spot the first by a `FieldError` that is not guarded by a preceding `if`, and the second by a `TestField` whose intent comment describes a condition more complex than presence or equality. diff --git a/community/knowledge/security/secrets-isolated-storage.bad.al b/microsoft/knowledge/security/secrets-isolated-storage.bad.al similarity index 100% rename from community/knowledge/security/secrets-isolated-storage.bad.al rename to microsoft/knowledge/security/secrets-isolated-storage.bad.al diff --git a/community/knowledge/security/secrets-isolated-storage.good.al b/microsoft/knowledge/security/secrets-isolated-storage.good.al similarity index 84% rename from community/knowledge/security/secrets-isolated-storage.good.al rename to microsoft/knowledge/security/secrets-isolated-storage.good.al index eec46ec..e6b9f38 100644 --- a/community/knowledge/security/secrets-isolated-storage.good.al +++ b/microsoft/knowledge/security/secrets-isolated-storage.good.al @@ -4,7 +4,7 @@ codeunit 50134 "Api Credential Good Sample" begin // Credentials live in IsolatedStorage, invisible to record reads, API // pages, RapidStart packages, and Excel export. - IsolatedStorage.Set('ExternalApiKey', ApiKey, DataScope::Module); + IsolatedStorage.SetEncrypted('ExternalApiKey', ApiKey, DataScope::Module); end; procedure GetApiKey() ApiKey: SecretText diff --git a/community/knowledge/security/secrets-isolated-storage.md b/microsoft/knowledge/security/secrets-isolated-storage.md similarity index 70% rename from community/knowledge/security/secrets-isolated-storage.md rename to microsoft/knowledge/security/secrets-isolated-storage.md index c87753d..91afe69 100644 --- a/community/knowledge/security/secrets-isolated-storage.md +++ b/microsoft/knowledge/security/secrets-isolated-storage.md @@ -9,15 +9,13 @@ application-area: [all] # A secret belongs in IsolatedStorage, never in a table field -> Contributions welcome — open a PR to refine or extend this article. - ## Description API keys, OAuth tokens, client secrets, and connection strings must not be stored in an ordinary table `Text` field — not even on a hidden setup table. A regular field is exposed through record reads, page display, RapidStart and Excel export, report datasets, and surfaces in `DataClassification` review; anyone with table permission can read it. The correct home is `IsolatedStorage`, which is invisible to database queries, API pages, and configuration packages. The storage-*location* decision is the rule here; how to scope and encrypt the value once it is in IsolatedStorage is covered separately. ## Best Practice -Persist every credential with `IsolatedStorage`, write it at the point of capture, and read it only when needed. For the per-secret details — choosing the right `DataScope`, encrypting at rest, and typing the value as `SecretText` so it cannot leak into logs — follow `isolatedstorage-datascope-module-vs-company`, `isolatedstorage-setencrypted-for-sensitive-values`, and `secrettext-for-credentials`. +Persist every credential in `IsolatedStorage`, write it at the point of capture, and read it only when needed. Prefer `SetEncrypted` when the value fits its documented length limit. On BC24 and later, carry the value through the `SecretText` overloads; on earlier releases, keep any required `Text` handling inside a `[NonDebuggable]` boundary. Choose the `DataScope` that matches the credential's lifetime. See `isolatedstorage-datascope-module-vs-company`, `isolatedstorage-setencrypted-for-sensitive-values`, and `secrettext-for-credentials` for those separate concerns. ## Anti Pattern diff --git a/community/knowledge/ui/fasttab-field-importance.md b/microsoft/knowledge/ui/fasttab-field-importance.md similarity index 63% rename from community/knowledge/ui/fasttab-field-importance.md rename to microsoft/knowledge/ui/fasttab-field-importance.md index f0ef844..f7de6dd 100644 --- a/community/knowledge/ui/fasttab-field-importance.md +++ b/microsoft/knowledge/ui/fasttab-field-importance.md @@ -1,20 +1,18 @@ ---- -bc-version: [all] -domain: ui -keywords: [importance, promoted, additional, fasttab, show-more, summary-line, progressive-disclosure, field-visibility] -technologies: [al] -countries: [w1] -application-area: [all] ---- -# Set Field Importance To Drive FastTab Progressive Disclosure - -> Contributions welcome — open a PR to refine or extend this article. - -## Description -A FastTab field's `Importance` property controls whether the field is visible immediately, hidden behind "Show more", or surfaced on the collapsed FastTab header summary line. The three values are `Standard` (the default, shown in the expanded FastTab), `Promoted` (also rendered on the FastTab header when the tab is collapsed), and `Additional` (hidden until the user clicks "Show more"). Misusing these values either clutters the summary line or buries fields users need on every transaction, so reviewers should treat `Importance` as a deliberate layout decision rather than an afterthought. - -## Best Practice -Promote only the two to four identifying fields per FastTab that users must read at a glance without expanding — name, status, key amount — so the collapsed header summary line stays scannable. Leave the everyday working fields at `Standard`, and push rarely-touched fields (legacy compatibility fields, system timestamps, seldom-changed configuration) to `Additional`. Note that field-level `Importance = Promoted` is unrelated to action promotion on the page action bar; it governs FastTab field visibility only. Do not rely on initial expand or collapse state, which you cannot set programmatically and which the platform may personalize per user — design assuming any FastTab may be collapsed. - -## Anti Pattern -Setting `Importance = Promoted` on most fields of a FastTab so "everything is important" defeats progressive disclosure: the collapsed summary line overflows and conveys nothing at a glance. The opposite failure is marking frequently edited fields `Additional`, forcing users to click "Show more" on every record. A detectable signal is a FastTab whose fields are nearly all `Promoted`, or a FastTab containing only `Additional` fields, which renders as an empty tab until expanded. +--- +bc-version: [all] +domain: ui +keywords: [importance, promoted, additional, fasttab, show-more, summary-line, progressive-disclosure, field-visibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Set Field Importance To Drive FastTab Progressive Disclosure + +## Description +A FastTab field's `Importance` property controls whether the field is visible immediately, hidden behind "Show more", or surfaced on the collapsed FastTab header summary line. The three values are `Standard` (the default, shown in the expanded FastTab), `Promoted` (also rendered on the FastTab header when the tab is collapsed), and `Additional` (hidden until the user clicks "Show more"). Misusing these values either clutters the summary line or buries fields users need on every transaction, so reviewers should treat `Importance` as a deliberate layout decision rather than an afterthought. + +## Best Practice +Promote only the small set of identifying fields per FastTab that users must read at a glance without expanding — name, status, key amount — so the collapsed header summary line stays scannable. Leave the everyday working fields at `Standard`, and push rarely-touched fields (legacy compatibility fields, system timestamps, seldom-changed configuration) to `Additional`. Note that field-level `Importance = Promoted` is unrelated to action promotion on the page action bar; it governs FastTab field visibility only. Do not rely on initial expand or collapse state, which you cannot set programmatically and which the platform may personalize per user — design assuming any FastTab may be collapsed. + +## Anti Pattern +Setting `Importance = Promoted` on most fields of a FastTab so "everything is important" defeats progressive disclosure: the collapsed summary line overflows and conveys nothing at a glance. The opposite failure is marking frequently edited fields `Additional`, forcing users to click "Show more" on every record. A detectable signal is a FastTab whose fields are nearly all `Promoted`, or a FastTab containing only `Additional` fields, which renders as an empty tab until expanded. diff --git a/community/knowledge/ui/page-background-tasks.md b/microsoft/knowledge/ui/page-background-tasks.md similarity index 96% rename from community/knowledge/ui/page-background-tasks.md rename to microsoft/knowledge/ui/page-background-tasks.md index 83fd87c..8a1d3fa 100644 --- a/community/knowledge/ui/page-background-tasks.md +++ b/microsoft/knowledge/ui/page-background-tasks.md @@ -1,20 +1,18 @@ ---- -bc-version: [all] -domain: ui -keywords: [enqueuebackgroundtask, async-calculation, child-session, factbox, cue-tile, onaftergetcurrrecord, responsive-page, read-only] -technologies: [al] -countries: [w1] -application-area: [all] ---- -# Offload Slow Read-Only Page Calculations To Background Tasks - -> Contributions welcome — open a PR to refine or extend this article. - -## Description -Pages that compute statistics, aggregates, or external lookups inline block the page from rendering until the calculation finishes, producing a visible freeze on FactBoxes, cue tiles, and calculated fields. Business Central provides page background tasks: `CurrPage.EnqueueBackgroundTask` runs a dedicated codeunit in a read-only child session and returns values via `OnPageBackgroundTaskCompleted`, so the page opens immediately and fills in computed values as they arrive. This matters because users should never wait on a calculation they may not need. The mechanism has specific rules that are easy to get wrong, which is why it warrants an explicit pattern. - -## Best Practice -Move any noticeable read-only computation off the synchronous render path into a background task. Enqueue from `OnAfterGetCurrRecord` so the task is tied to the currently focused record, and pass small payloads through the `Dictionary of [Text, Text]` input/output, converting types with `Format` and `Evaluate`. Keep each task focused on one value or a small related set rather than one large task, and show a placeholder until results land. Because tasks auto-cancel when the page closes, the record changes, or a same-ID task is re-enqueued, always supply sensible defaults and handle the timeout path in `OnPageBackgroundTaskError` — never let critical functionality depend on completion. For tests, drive the task synchronously with `RunPageBackgroundTask`. - -## Anti Pattern -Enqueuing from `OnAfterGetRecord` on a list page fires the task for every row, and each cancels the instant the selection moves to the next row — pure wasted child-session churn; a reviewer spots `EnqueueBackgroundTask` called from `OnAfterGetRecord` (or from `OnOpenPage`, where the record context is not yet stable). The other tell is a task codeunit attempting a database write or `Modify`: background tasks run read-only and the write fails at runtime. Inline heavy calculation directly in `OnAfterGetCurrRecord` with no task at all is the baseline smell — it reintroduces the page freeze the feature exists to remove. +--- +bc-version: [all] +domain: ui +keywords: [enqueuebackgroundtask, async-calculation, child-session, factbox, cue-tile, onaftergetcurrrecord, responsive-page, read-only] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Offload Slow Read-Only Page Calculations To Background Tasks + +## Description +Pages that compute statistics, aggregates, or external lookups inline block the page from rendering until the calculation finishes, producing a visible freeze on FactBoxes, cue tiles, and calculated fields. Business Central provides page background tasks: `CurrPage.EnqueueBackgroundTask` runs a dedicated codeunit in a read-only child session and returns values via `OnPageBackgroundTaskCompleted`, so the page opens immediately and fills in computed values as they arrive. This matters because users should never wait on a calculation they may not need. The mechanism has specific rules that are easy to get wrong, which is why it warrants an explicit pattern. + +## Best Practice +Move any noticeable read-only computation off the synchronous render path into a background task. Enqueue from `OnAfterGetCurrRecord` so the task is tied to the currently focused record, and pass small payloads through the `Dictionary of [Text, Text]` input/output, converting types with `Format` and `Evaluate`. Keep each task focused on one value or a small related set rather than one large task, and show a placeholder until results land. Because tasks auto-cancel when the page closes, the record changes, or a same-ID task is re-enqueued, always supply sensible defaults and handle the timeout path in `OnPageBackgroundTaskError` — never let critical functionality depend on completion. For tests, drive the task synchronously with `RunPageBackgroundTask`. + +## Anti Pattern +Enqueuing from `OnAfterGetRecord` on a list page fires the task for every row, and each cancels the instant the selection moves to the next row — pure wasted child-session churn; a reviewer spots `EnqueueBackgroundTask` called from `OnAfterGetRecord` (or from `OnOpenPage`, where the record context is not yet stable). The other tell is a task codeunit attempting a database write or `Modify`: background tasks run read-only and the write fails at runtime. Inline heavy calculation directly in `OnAfterGetCurrRecord` with no task at all is the baseline smell — it reintroduces the page freeze the feature exists to remove. diff --git a/community/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md b/microsoft/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md similarity index 95% rename from community/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md rename to microsoft/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md index a3fc9dd..b0e66f0 100644 --- a/community/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md +++ b/microsoft/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md @@ -1,20 +1,18 @@ ---- -bc-version: [21..] -domain: ui -keywords: [actionref, promoted-actions, area-promoted, promotedcategory, promotedonly, action-bar, legacy-syntax] -technologies: [al] -countries: [w1] -application-area: [all] ---- -# Promote Actions With The Modern `actionref` Syntax, Never The Legacy `Promoted` Properties - -> Contributions welcome — open a PR to refine or extend this article. - -## Description -Business Central 2022 release wave 2 (v21) introduced the `area(Promoted)` block with `actionref` as the way to promote page actions, separating an action's definition from its promotion. The older approach set `Promoted`, `PromotedCategory`, `PromotedOnly`, and `PromotedIsBig` directly on each action. The two syntaxes cannot be mixed within a single page or page extension, and choosing the legacy one entangles definition with presentation, making the action bar harder to maintain and to extend. - -## Best Practice -For new pages and page extensions, define actions in their normal `area`, then promote selected ones with `actionref` inside `area(Promoted)`, grouping them under explicit categories such as `Category_Process` and entity-named groups. This keeps each action defined once and referenced where it should appear, supports split buttons via `ShowAs`, and lets an extension promote a base action without redefining it. When extending a page, you may use modern syntax even if the base page used legacy properties (and vice versa) — the no-mixing rule is per-object, not per-dependency-tree. - -## Anti Pattern -Setting `Promoted = true` (with `PromotedCategory`, `PromotedOnly`, or `PromotedIsBig`) on actions in new code, or attempting to combine those properties with an `area(Promoted)` block in the same object — the latter fails to compile. The reviewer signal is any `Promoted`-prefixed property on an action in a newly authored page or page extension; flag it and convert to `actionref` (VS Code offers an automated conversion). Note separately that once an action is promoted in a published app, removing the promotion is a breaking change (AS0031/AW0013), so promote conservatively rather than walking it back later. +--- +bc-version: [21..] +domain: ui +keywords: [actionref, promoted-actions, area-promoted, promotedcategory, promotedonly, action-bar, legacy-syntax] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Promote Actions With The Modern `actionref` Syntax, Never The Legacy `Promoted` Properties + +## Description +Business Central 2022 release wave 2 (v21) introduced the `area(Promoted)` block with `actionref` as the way to promote page actions, separating an action's definition from its promotion. The older approach set `Promoted`, `PromotedCategory`, `PromotedOnly`, and `PromotedIsBig` directly on each action. The two syntaxes cannot be mixed within a single page or page extension, and choosing the legacy one entangles definition with presentation, making the action bar harder to maintain and to extend. + +## Best Practice +For new pages and page extensions, define actions in their normal `area`, then promote selected ones with `actionref` inside `area(Promoted)`, grouping them under explicit categories such as `Category_Process` and entity-named groups. This keeps each action defined once and referenced where it should appear, supports split buttons via `ShowAs`, and lets an extension promote a base action without redefining it. When extending a page, you may use modern syntax even if the base page used legacy properties (and vice versa) — the no-mixing rule is per-object, not per-dependency-tree. + +## Anti Pattern +Setting `Promoted = true` (with `PromotedCategory`, `PromotedOnly`, or `PromotedIsBig`) on actions in new code, or attempting to combine those properties with an `area(Promoted)` block in the same object — the latter fails to compile. The reviewer signal is any `Promoted`-prefixed property on an action in a newly authored page or page extension; flag it and convert to `actionref` (VS Code offers an automated conversion). Note separately that once an action is promoted in a published app, removing the promotion is a breaking change (AS0031/AW0013), so promote conservatively rather than walking it back later. diff --git a/community/knowledge/ui/promoted-action-groups.md b/microsoft/knowledge/ui/promoted-action-groups.md similarity index 71% rename from community/knowledge/ui/promoted-action-groups.md rename to microsoft/knowledge/ui/promoted-action-groups.md index 4e009ad..e2e8883 100644 --- a/community/knowledge/ui/promoted-action-groups.md +++ b/microsoft/knowledge/ui/promoted-action-groups.md @@ -1,20 +1,18 @@ ---- -bc-version: [21..] -domain: ui -keywords: [action-groups, area-promoted, actionref, showas, split-button, group-caption, navigate-group, entity-group] -technologies: [al] -countries: [w1] -application-area: [all] ---- -# Use Standard Promoted Action Group Names And Placements - -> Contributions welcome — open a PR to refine or extend this article. - -## Description -Business Central ships a fixed vocabulary of promoted action groups, and users build muscle memory around where each kind of action lives. When you define `area(Promoted)` groups, reusing the standard caption and placement for a given action class makes the page feel native; inventing your own caption or putting an action in the wrong group forces every user to relearn your page. Frontier models tend to emit plausible-but-nonstandard captions (`Go To`, `Vendor Actions`, `Related`) instead of the established BC names, which is exactly what breaks cross-page consistency. - -## Best Practice -Map each action to its conventional group and use the exact standard caption: `Home`/`Process` for data-modifying and workflow actions (entity/card/document pages use `Home`, lists and worksheets use `Process`); an entity-named group (`Customer`, `Item`, `Order`) for navigation tied to the current record (statistics, ledger entries, dimensions); `Navigate` for related pages that are useful regardless of the selected record; `Report` for printing and analysis; and the workflow groups `Posting`, `Release`, `Approve`, `Request Approval`, and `Prepare` for their respective document lifecycle actions. Only `Posting` (Post / Post and Print / Preview) and `Release` (Release / Reopen) should render as split buttons via `ShowAs = SplitButton`; everything else is a normal dropdown. Within a common group keep the same action sequence you see on the matching base-app page (e.g. mirror Sales Order for a sales document) so order stays predictable. - -## Anti Pattern -Custom captions for what is really a standard group (`Vendor Actions` instead of the `Vendor` entity group, `Go To` instead of `Navigate`), posting or statistics actions dropped into the wrong group, or many tiny one-action groups that fragment the ribbon. The reviewer signal is an `area(Promoted)` block whose `group` captions do not match the base-application names for the same page type, or a `ShowAs = SplitButton` on anything other than `Posting`/`Release`. +--- +bc-version: [21..] +domain: ui +keywords: [action-groups, area-promoted, actionref, showas, split-button, group-caption, navigate-group, entity-group] +technologies: [al] +countries: [w1] +application-area: [all] +--- +# Use Standard Promoted Action Group Names And Placements + +## Description +Business Central ships a fixed vocabulary of promoted action groups, and users build muscle memory around where each kind of action lives. When you define `area(Promoted)` groups, reusing the standard caption and placement for a given action class makes the page feel native; inventing your own caption or putting an action in the wrong group forces every user to relearn your page. Frontier models tend to emit plausible-but-nonstandard captions (`Go To`, `Vendor Actions`, `Related`) instead of the established BC names, which is exactly what breaks cross-page consistency. + +## Best Practice +Map each action to its conventional group and use the exact standard caption: `Home`/`Process` for data-modifying and workflow actions (entity/card/document pages use `Home`, lists and worksheets use `Process`); an entity-named group (`Customer`, `Item`, `Order`) for navigation tied to the current record (statistics, ledger entries, dimensions); `Navigate` for related pages that are useful regardless of the selected record; `Report` for printing and analysis; and the workflow groups `Posting`, `Release`, `Approve`, `Request Approval`, and `Prepare` for their respective document lifecycle actions. Standard guidance recommends `ShowAs = SplitButton` for `Posting` (Post / Post and Print / Preview) and `Release` (Release / Reopen), while the other common groups normally render as standard groups. Use a split button elsewhere only for closely related alternatives with an obvious primary action. The first enabled and visible action becomes the primary button, so place the expected default first and remember that extensions or personalization can reorder it. Within a common group keep the same action sequence you see on the matching base-app page (for example, mirror Sales Order for a sales document) so order stays predictable. + +## Anti Pattern +Custom captions for what is really a standard group (`Vendor Actions` instead of the `Vendor` entity group, `Go To` instead of `Navigate`), posting or statistics actions dropped into the wrong group, or many tiny one-action groups that fragment the ribbon. The reviewer signal is an `area(Promoted)` block whose `group` captions do not match the base-application names for the same page type, or a split button whose actions are unrelated or lack an obvious primary operation. diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md index b5490fe..14af7bf 100644 --- a/microsoft/skills/review/al-appsource-review.md +++ b/microsoft/skills/review/al-appsource-review.md @@ -46,7 +46,7 @@ A file enters the candidate worklist when its `keywords` intersect the extracted The following targeted checks cover every current `appsource` article across the Microsoft and community layers. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action. - A new or renamed object lacks the reserved prefix/suffix, or a tableextension/pageextension/reportextension adds an unaffixed field, key, control, or action to a base object despite `mandatoryAffixes`/`mandatoryPrefix` and AS0011 — `object-affixes-prevent-collisions`. -- For BC v24 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent — `keep-copilot-help-url-to-two-path-levels`. +- For BC v27 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent — `keep-copilot-help-url-to-two-path-levels`. 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`. diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 2e68e36..df25e4d 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -146,15 +146,15 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "from-sub-skill": "al-performance-review" }, { - "id": "community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md", + "id": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md", "severity": "minor", - "message": "The loop reads an unlisted field after SetLoadFields, triggering a hidden JIT load for each record passed by value.", + "message": "The loop reads only a small subset of fields from a wide table without SetLoadFields, transferring every column for each row.", "location": { "file": "src/Sales/PostingRoutines.Codeunit.al", "line": 152 }, "references": [ - { "path": "community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md" } + { "path": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md" } ], "confidence": "high", "from-sub-skill": "al-performance-review" @@ -175,7 +175,7 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "from-sub-skill": "al-security-review" }, { - "id": "community/knowledge/security/secrets-isolated-storage.md", + "id": "microsoft/knowledge/security/secrets-isolated-storage.md", "severity": "minor", "message": "A setup table stores an API key in an ordinary Text field, exposing it through table reads and exports. Persist it in IsolatedStorage instead.", "location": { @@ -183,7 +183,7 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "line": 12 }, "references": [ - { "path": "community/knowledge/security/secrets-isolated-storage.md" } + { "path": "microsoft/knowledge/security/secrets-isolated-storage.md" } ], "confidence": "medium", "from-sub-skill": "al-security-review" @@ -227,15 +227,15 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "confidence": "high" }, { - "id": "community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md", + "id": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md", "severity": "minor", - "message": "The loop reads an unlisted field after SetLoadFields, triggering a hidden JIT load for each record passed by value.", + "message": "The loop reads only a small subset of fields from a wide table without SetLoadFields, transferring every column for each row.", "location": { "file": "src/Sales/PostingRoutines.Codeunit.al", "line": 152 }, "references": [ - { "path": "community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md" } + { "path": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md" } ], "confidence": "high" } @@ -265,7 +265,7 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "confidence": "high" }, { - "id": "community/knowledge/security/secrets-isolated-storage.md", + "id": "microsoft/knowledge/security/secrets-isolated-storage.md", "severity": "minor", "message": "A setup table stores an API key in an ordinary Text field, exposing it through table reads and exports. Persist it in IsolatedStorage instead.", "location": { @@ -273,7 +273,7 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "line": 12 }, "references": [ - { "path": "community/knowledge/security/secrets-isolated-storage.md" } + { "path": "microsoft/knowledge/security/secrets-isolated-storage.md" } ], "confidence": "medium" } diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index 50bd9e9..eaf252b 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-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 codeunits that post or validate, tables and table extensions with `OnValidate` triggers, and any procedure that raises errors or orchestrates a batch over records. - The changed procedures and triggers, weighted toward `OnValidate`/`OnInsert`/`OnModify` triggers, posting and validation routines, and procedures attributed with `[ErrorBehavior(...)]` or `[TryFunction]`. -- Tokens extracted from the diff that relate to error surfacing and diagnostics (`Error`, `ErrorInfo`, `Title`, `Message`, `DetailedMessage`, `AddAction`, `AddNavigationAction`, `RecordId`, `PageNo`, `ErrorBehavior`, `Collect`, `HasCollectedErrors`, `GetCollectedErrors`, `ClearCollectedErrors`, `ErrorType`, `Internal`, `Client`, `TryFunction`, `GetLastErrorText`, Boolean assignment). +- Tokens extracted from the diff that relate to error surfacing and diagnostics (`Error`, `ErrorInfo`, `FieldError`, `TestField`, `Title`, `Message`, `DetailedMessage`, `AddAction`, `AddNavigationAction`, `RecordId`, `PageNo`, `ErrorBehavior`, `Collect`, `HasCollectedErrors`, `GetCollectedErrors`, `ClearCollectedErrors`, `ErrorType`, `Internal`, `Client`, `TryFunction`, `GetLastErrorText`, Boolean assignment). - Resolve changed standalone call targets; when the target declaration has `[TryFunction]`, worklist the ignored-return rule even if the declaration itself is unchanged. Only assignment and conditional use activate try semantics. A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index a72d8ef..9bd23dc 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -111,15 +111,15 @@ Output conforms to the DO output contract. A populated example: "confidence": "high" }, { - "id": "community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md", + "id": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md", "severity": "minor", - "message": "The loop reads an unlisted field after SetLoadFields, triggering a hidden JIT load for each record passed by value.", + "message": "The loop reads only a small subset of fields from a wide table without SetLoadFields, transferring every column for each row.", "location": { "file": "src/Sales/PostingRoutines.Codeunit.al", "line": 152 }, "references": [ - { "path": "community/knowledge/performance/setloadfields-unlisted-field-triggers-jit-load.md" } + { "path": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md" } ], "confidence": "high" } diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index 1986934..e6ee82b 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -103,7 +103,7 @@ Output conforms to the DO output contract. A populated example: "confidence": "high" }, { - "id": "community/knowledge/security/secrets-isolated-storage.md", + "id": "microsoft/knowledge/security/secrets-isolated-storage.md", "severity": "minor", "message": "A setup table stores an API key in an ordinary Text field, exposing it through table reads and exports. Persist it in IsolatedStorage instead.", "location": { @@ -111,7 +111,7 @@ Output conforms to the DO output contract. A populated example: "line": 12 }, "references": [ - { "path": "community/knowledge/security/secrets-isolated-storage.md" } + { "path": "microsoft/knowledge/security/secrets-isolated-storage.md" } ], "confidence": "medium" } diff --git a/microsoft/skills/review/al-telemetry-review.md b/microsoft/skills/review/al-telemetry-review.md index 3c0a723..3cad1c2 100644 --- a/microsoft/skills/review/al-telemetry-review.md +++ b/microsoft/skills/review/al-telemetry-review.md @@ -43,10 +43,6 @@ Narrow the relevant files to the subset that applies to the changes under review A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no telemetry-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. -The following targeted check covers every current `telemetry` article across the Microsoft and community layers. Treat it as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action. - -- `Session.LogMessage` or `Session.LogError` uses `TelemetryScope::All` for publisher-only diagnostics, a telemetry wrapper defaults its scope to `All`, or a `FeatureTelemetry`/custom logging change routes signals to customer environment telemetry without a customer-actionable reason — `default-telemetryscope-to-extensionpublisher`. - 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 telemetry knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable telemetry knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. @@ -81,37 +77,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: - -```json -{ - "skill": { "id": "al-telemetry-review", "version": 1 }, - "outcome": "completed", - "summary": { - "counts": { "blocker": 0, "major": 1, "minor": 0, "info": 0 }, - "coverage": { "worklist-size": 1, "items-evaluated": 1 } - }, - "findings": [ - { - "id": "community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.md", - "severity": "major", - "message": "This publisher-only diagnostic uses TelemetryScope::All, which also sends it to each customer's environment telemetry and adds avoidable ingestion cost.", - "location": { - "file": "src/Telemetry/Diagnostics.Codeunit.al", - "line": 31 - }, - "references": [ - { "path": "community/knowledge/telemetry/default-telemetryscope-to-extensionpublisher.md" } - ], - "confidence": "high", - "suggested-code": "TelemetryScope::ExtensionPublisher" - } - ], - "suppressed": [] -} -``` - -The empty-corpus case produces: +Output conforms to the DO output contract. The empty-corpus case produces: ```json { diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index e3ada1b..ef22fa3 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -40,8 +40,8 @@ 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. - **UI-file filter.** UI review applies to files declaring `page`, `pageextension`, or `pagecustomization`, and to JavaScript/CSS/HTML that implements a control add-in's rendering or Business Central communication. 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 files, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, `ShowCaption`, `InstructionalText`, `GridLayout`, `Style`, `StyleExpr`, action definitions, field-level properties, DOM creation, ARIA attributes, keyboard/focus handlers, packaged-resource AJAX, and calls from JavaScript into AL. -- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `control-add-in`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `packaged-resource`, `ajax`, `$.get`, `$.ajax`, `XMLHttpRequest`, `xhrFields`, `withCredentials`, `withcredentials`, `InvokeExtensibilityMethod`, `invokeextensibilitymethod`, `skipIfBusy`, `successCallback`, `success-callback`, `errorCallback`, `setInterval`, `JSON.stringify`, `payload`, `throttling`, `reduced-functionality`, `ClientServicesMaxUploadSize`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions). +- For each relevant knowledge file, compute overlap against changed page declarations and control add-in files, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, `ShowCaption`, `InstructionalText`, `GridLayout`, `Style`, `StyleExpr`, promoted action definitions, field importance, page background tasks, DOM creation, ARIA attributes, keyboard/focus handlers, packaged-resource AJAX, and calls from JavaScript into AL. +- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Importance`, `Promoted`, `Additional`, `area(Promoted)`, `actionref`, `PromotedCategory`, `PromotedOnly`, `PromotedIsBig`, `ShowAs`, `SplitButton`, `EnqueueBackgroundTask`, `OnAfterGetCurrRecord`, `OnAfterGetRecord`, `OnPageBackgroundTaskCompleted`, `OnPageBackgroundTaskError`, `RunPageBackgroundTask`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `control-add-in`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `packaged-resource`, `ajax`, `$.get`, `$.ajax`, `XMLHttpRequest`, `xhrFields`, `withCredentials`, `withcredentials`, `InvokeExtensibilityMethod`, `invokeextensibilitymethod`, `skipIfBusy`, `successCallback`, `success-callback`, `errorCallback`, `setInterval`, `JSON.stringify`, `payload`, `throttling`, `reduced-functionality`, `ClientServicesMaxUploadSize`, `&`, `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 (derived from the index entry's `path`, `title`, and `description`) matches a changed page element. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. From 809af9708e265f110ced6752fd8f2e40f1efc820 Mon Sep 17 00:00:00 2001 From: Wenjie Fan <31087545+gggdttt@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:23:03 +0200 Subject: [PATCH 25/86] Privacy DataClassification fixes + keep Label-scope findings at minor (#102) * Fix DataClassification default fact and broaden classification taxonomy * Keep Label-scope findings at minor; no analyzer enforces label scope --------- Co-authored-by: wenjiefan --- .../privacy/data-classification-required-on-pii-fields.md | 4 ++-- microsoft/skills/review/al-style-review.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md index 808e103..d3e1e55 100644 --- a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md @@ -11,11 +11,11 @@ application-area: [all] ## 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. +`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, customer, or organization data. When the property is omitted, AL applies `ToBeClassified` — a placeholder meaning "not yet reviewed", not a safe default. Leaving a field that actually holds PII (an email address, a customer name, an employee code) as `ToBeClassified`, or setting it to `SystemMetadata` ("no user or customer data") to silence the requirement, are both under-classifications and privacy bugs, 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. +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. A field that identifies an organization rather than a person — a company registration or VAT registration number — is `OrganizationIdentifiableInformation`, and a financial account identifier such as a bank account number or IBAN is `AccountData`. Choose the classification at field definition time — fixing it later is a schema change. See sample: `data-classification-required-on-pii-fields.good.al`. diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index 74dac1f..68f9834 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -53,7 +53,7 @@ When the post-conflict worklist is empty because no applicable style knowledge e 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`. -Severity calibration — a formal analyzer already flags the mechanical presence/naming conventions (the `this` keyword AA0248, approved label suffixes AA0074, a missing `ToolTip`, a `Label` declared at local instead of object scope, required parentheses). On those, BCQuality's value is the *explanation* of why the rule exists, not a second gate; emit them at `info` so a consumer that gates on severity does not re-flag what CodeCop/AppSourceCop already reports. Reserve `minor` for style issues with concrete downstream impact the analyzer does not catch — lost translation or telemetry classification from a string-built `Error`, an `OptionCaption` that does not match its `OptionMembers`, a misleading named invocation. This keeps the domain's default output advisory and prevents analyzer-redundant noise from competing with substantive review. +Severity calibration — a formal analyzer already flags the mechanical presence/naming conventions (the `this` keyword AA0248, approved label suffixes AA0074, a missing `ToolTip`, required parentheses). On those, BCQuality's value is the *explanation* of why the rule exists, not a second gate; emit them at `info` so a consumer that gates on severity does not re-flag what CodeCop/AppSourceCop already reports. Reserve `minor` for style issues with concrete downstream impact the analyzer does not catch — a `Label` declared at procedure-local instead of object scope (no analyzer enforces label scope, and mis-scoped Labels are fragile in the translation pipeline), lost translation or telemetry classification from a string-built `Error`, an `OptionCaption` that does not match its `OptionMembers`, a misleading named invocation. This keeps the domain's default output advisory and prevents analyzer-redundant noise from competing with substantive review. Set `confidence` to: From ae04938c03bc360a45558d21b7df9345ba5dafcb Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Wed, 15 Jul 2026 10:44:50 +0200 Subject: [PATCH 26/86] Emit human-readable domain label on review findings (#54) * Emit human-readable domain label on review findings Add an optional findings[].domain field to the DO review output contract so each finding carries its own human-readable review-domain display label. Leaf review skills set it on every finding they emit; the al-code-review super-skill copies it verbatim during rollup and sets it to "Agent" for its own cross-cutting agent findings. This decouples consumers from BCQuality's domain taxonomy: they render finding.domain verbatim instead of maintaining a sub-skill-id -> label map. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Define domain display-label constraints Clarify that review domains may contain internal whitespace, punctuation, case-sensitive text, and non-ASCII characters. Require consumers to preserve and safely encode the complete label instead of relying on lossy slugs, matching the replacement BC-ALAgents consumer. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 77d0a40e-8bf5-40ac-a450-40eb0255db03 --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- agent-consumption.md | 11 ++++--- .../skills/review/al-appsource-review.md | 3 +- .../review/al-breaking-changes-review.md | 8 +++-- microsoft/skills/review/al-code-review.md | 32 ++++++++++++------- .../skills/review/al-data-modeling-review.md | 3 +- .../skills/review/al-error-handling-review.md | 8 +++-- microsoft/skills/review/al-events-review.md | 8 +++-- .../skills/review/al-interfaces-review.md | 8 +++-- .../skills/review/al-performance-review.md | 8 +++-- microsoft/skills/review/al-privacy-review.md | 5 +-- microsoft/skills/review/al-security-review.md | 8 +++-- microsoft/skills/review/al-style-review.md | 5 +-- .../skills/review/al-telemetry-review.md | 2 +- microsoft/skills/review/al-testing-review.md | 3 +- microsoft/skills/review/al-ui-review.md | 5 +-- microsoft/skills/review/al-upgrade-review.md | 5 +-- .../skills/review/al-web-services-review.md | 8 +++-- skills/bcquality-al-review/SKILL.md | 8 ++--- skills/do.md | 9 ++++-- 19 files changed, 91 insertions(+), 56 deletions(-) diff --git a/agent-consumption.md b/agent-consumption.md index 0d2e5b4..8db80d6 100644 --- a/agent-consumption.md +++ b/agent-consumption.md @@ -21,7 +21,7 @@ flowchart LR E -->|3 dispatch record| A A -->|4 invoke dispatched skill| S[Action skill
e.g. al-code-review] S -->|5 execute| P[Source → Relevance
→ Worklist → Action
reading READ · DO on demand] - P -->|6 emit| R[Findings · References
· Confidence] + P -->|6 emit| R[Findings · Domain labels
· References · Confidence] R -->|7 integrate| O ``` @@ -65,6 +65,7 @@ The output contract is defined in the DO meta-skill so that every action skill - **Outcome** — `completed`, `not-applicable`, `no-knowledge`, `partial`, or `failed`. An orchestrator can distinguish a clean run from a no-op from a failure without guessing. - **Findings** — what the skill observed (severity, message, optional location). +- **Domain** — the producer-owned, human-readable display label on each review finding. - **References** — structured objects (`path` plus optional commit `sha`) pointing to the knowledge files that informed each finding. - **Confidence** — per-finding evidence strength. - **Suppressed** — knowledge files that were discarded by layer precedence or configuration, so reviewers can see what was overridden. @@ -78,12 +79,12 @@ The orchestrator turns findings into PR comments, build gates, or IDE diagnostic 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. +- **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. Leaf sub-skills set `domain` to their human-readable display label, and super-skills preserve it verbatim during rollup. +- **Agent findings** carry an empty `references: []`, use a slug `id` prefixed `agent:`, and have `confidence` capped at `medium`. A leaf can emit one strictly within its own domain and uses that leaf's display label. A super-skill can emit a cross-cutting agent finding with `from-sub-skill: "agent"` and `domain: "Agent"`. 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. +Before a 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 relevant existing 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. +Orchestrators MUST tolerate an absent `domain` in reports from older producers. When it is present, treat it as display text rather than an identifier: preserve the full string and its case, whitespace, punctuation, and non-ASCII characters, escaping only for the target rendering format. Do not tokenize it on spaces or use a lowercased or slugified form as the sole metadata or deduplication key, because distinct labels can collapse to the same slug. Retain the exact string, use a lossless encoding, or use a collision-resistant digest instead. Orchestrators MAY render knowledge-backed and agent findings differently and MAY apply independent severity floors; `references: []` and the `agent:` id prefix distinguish agent findings, while `from-sub-skill: "agent"` identifies those emitted by the super-skill itself. ## Why this architecture diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md index 14af7bf..cf09619 100644 --- a/microsoft/skills/review/al-appsource-review.md +++ b/microsoft/skills/review/al-appsource-review.md @@ -82,7 +82,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"AppSource"`. A populated example: ```json { @@ -105,6 +105,7 @@ Output conforms to the DO output contract. A populated example: { "path": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md" } ], "confidence": "high", + "domain": "AppSource", "suggested-code": "field(50100; \"Loyalty Points ABC\"; Integer)" } ], diff --git a/microsoft/skills/review/al-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md index 4238bca..0af5abc 100644 --- a/microsoft/skills/review/al-breaking-changes-review.md +++ b/microsoft/skills/review/al-breaking-changes-review.md @@ -77,7 +77,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Breaking Changes"`. A populated example: ```json { @@ -100,7 +100,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/breaking-changes/do-not-change-published-procedure-signatures.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Breaking Changes" }, { "id": "microsoft/knowledge/breaking-changes/choose-access-modifiers-deliberately.md", @@ -113,7 +114,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/breaking-changes/choose-access-modifiers-deliberately.md" } ], - "confidence": "medium" + "confidence": "medium", + "domain": "Breaking Changes" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index df25e4d..a7cb8d9 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -76,7 +76,7 @@ For each sub-skill in the worklist, executed one at a time per the discipline ab 1. Invoke the sub-skill with the orchestrator's inputs, passing only the subset each sub-skill declares in its `inputs`. 2. Capture the sub-skill's complete findings-report verbatim and append it to `sub-results`. 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. +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` and preserving each finding's optional `domain` field verbatim, including its absence. 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. ### Agent self-review pass @@ -89,11 +89,12 @@ Frame the pass by cross-cutting concerns — architecture, error handling, resou 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 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, set `domain` to the human-readable label required by that sub-skill's Output contract, 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 a super-skill agent finding. 2. **Emit agent finding.** Per DO's *Agent findings* rules: - `from-sub-skill: "agent"` (the super-skill itself produced it) + - `domain: "Agent"` (the display label for super-skill cross-cutting findings) - `references: []` - `id` is a skill-defined slug prefixed with `agent:` (for example, `agent:missing-error-handling-on-http-call`). - `confidence` capped at `medium`. @@ -143,7 +144,8 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip { "path": "microsoft/knowledge/performance/apply-filters-before-iterating.md" } ], "confidence": "high", - "from-sub-skill": "al-performance-review" + "from-sub-skill": "al-performance-review", + "domain": "Performance" }, { "id": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md", @@ -157,7 +159,8 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip { "path": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md" } ], "confidence": "high", - "from-sub-skill": "al-performance-review" + "from-sub-skill": "al-performance-review", + "domain": "Performance" }, { "id": "microsoft/knowledge/security/secrettext-for-credentials.md", @@ -172,7 +175,8 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip { "path": "microsoft/knowledge/security/secrettext-for-credentials.md" } ], "confidence": "high", - "from-sub-skill": "al-security-review" + "from-sub-skill": "al-security-review", + "domain": "Security" }, { "id": "microsoft/knowledge/security/secrets-isolated-storage.md", @@ -186,7 +190,8 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip { "path": "microsoft/knowledge/security/secrets-isolated-storage.md" } ], "confidence": "medium", - "from-sub-skill": "al-security-review" + "from-sub-skill": "al-security-review", + "domain": "Security" }, { "id": "agent:missing-error-handling-on-http-client", @@ -199,7 +204,8 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip }, "references": [], "confidence": "medium", - "from-sub-skill": "agent" + "from-sub-skill": "agent", + "domain": "Agent" } ], "suppressed": [], @@ -224,7 +230,8 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "references": [ { "path": "microsoft/knowledge/performance/apply-filters-before-iterating.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Performance" }, { "id": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md", @@ -237,7 +244,8 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "references": [ { "path": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Performance" } ], "suppressed": [] @@ -262,7 +270,8 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "references": [ { "path": "microsoft/knowledge/security/secrettext-for-credentials.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Security" }, { "id": "microsoft/knowledge/security/secrets-isolated-storage.md", @@ -275,7 +284,8 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip "references": [ { "path": "microsoft/knowledge/security/secrets-isolated-storage.md" } ], - "confidence": "medium" + "confidence": "medium", + "domain": "Security" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 7cbe48d..ca10e73 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -85,7 +85,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Data Modeling"`. A populated example: ```json { @@ -108,6 +108,7 @@ Output conforms to the DO output contract. A populated example: { "path": "microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md" } ], "confidence": "high", + "domain": "Data Modeling", "suggested-code": "trigger OnRename()\nbegin\n \"Last Date Modified\" := Today();\nend;" } ], diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index eaf252b..3b7d6cf 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-review.md @@ -78,7 +78,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Error Handling"`. A populated example: ```json { @@ -101,7 +101,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/error-handling/prefer-errorinfo-for-actionable-errors.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Error Handling" }, { "id": "microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md", @@ -114,7 +115,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md" } ], - "confidence": "medium" + "confidence": "medium", + "domain": "Error Handling" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index 2d7a76d..ef4be0e 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-review.md @@ -96,7 +96,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Events"`. A populated example: ```json { @@ -119,7 +119,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Events" }, { "id": "microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.md", @@ -132,7 +133,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Events" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-interfaces-review.md b/microsoft/skills/review/al-interfaces-review.md index 65db35e..a52d451 100644 --- a/microsoft/skills/review/al-interfaces-review.md +++ b/microsoft/skills/review/al-interfaces-review.md @@ -85,7 +85,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Interfaces"`. A populated example: ```json { @@ -108,7 +108,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/interfaces/prefer-interface-over-case-branching.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Interfaces" }, { "id": "microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md", @@ -121,7 +122,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Interfaces" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index 9bd23dc..f27beb3 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -85,7 +85,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Performance"`. A populated example: ```json { @@ -108,7 +108,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/performance/apply-filters-before-iterating.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Performance" }, { "id": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md", @@ -121,7 +122,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Performance" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index 2cd4005..0988622 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -79,7 +79,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Privacy"`. A populated example: ```json { @@ -102,7 +102,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Privacy" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index e6ee82b..23a93cf 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -77,7 +77,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Security"`. A populated example: ```json { @@ -100,7 +100,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/security/secrettext-for-credentials.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Security" }, { "id": "microsoft/knowledge/security/secrets-isolated-storage.md", @@ -113,7 +114,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/security/secrets-isolated-storage.md" } ], - "confidence": "medium" + "confidence": "medium", + "domain": "Security" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index 68f9834..37b9a15 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -77,7 +77,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Style"`. A populated example: ```json { @@ -99,7 +99,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/style/label-suffix-approved-list.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Style" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-telemetry-review.md b/microsoft/skills/review/al-telemetry-review.md index 3cad1c2..e4169a2 100644 --- a/microsoft/skills/review/al-telemetry-review.md +++ b/microsoft/skills/review/al-telemetry-review.md @@ -77,7 +77,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. The empty-corpus case produces: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Telemetry"`. The empty-corpus case produces: ```json { diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index 54aeded..01f8f16 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-review.md @@ -84,7 +84,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Testing"`. A populated example: ```json { @@ -107,6 +107,7 @@ Output conforms to the DO output contract. A populated example: { "path": "microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md" } ], "confidence": "high", + "domain": "Testing", "suggested-code": "asserterror PostInvalidOrder();\nAssert.ExpectedError(ExpectedPostingErr);" } ], diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index ef22fa3..19f03c9 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -77,7 +77,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Accessibility"`. A populated example: ```json { @@ -99,7 +99,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/ui/show-caption-on-editable-fields.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Accessibility" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md index c087585..2cecf0c 100644 --- a/microsoft/skills/review/al-upgrade-review.md +++ b/microsoft/skills/review/al-upgrade-review.md @@ -80,7 +80,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Upgrade"`. A populated example: ```json { @@ -102,7 +102,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/upgrade/enum-values-additive-at-end.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Upgrade" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md index 1136a4d..551dce5 100644 --- a/microsoft/skills/review/al-web-services-review.md +++ b/microsoft/skills/review/al-web-services-review.md @@ -80,7 +80,7 @@ Outcome selection: ## Output -Output conforms to the DO output contract. A populated example: +Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Web Services"`. A populated example: ```json { @@ -103,7 +103,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/web-services/set-required-api-page-properties.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Web Services" }, { "id": "microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md", @@ -116,7 +117,8 @@ Output conforms to the DO output contract. A populated example: "references": [ { "path": "microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md" } ], - "confidence": "high" + "confidence": "high", + "domain": "Web Services" } ], "suppressed": [] diff --git a/skills/bcquality-al-review/SKILL.md b/skills/bcquality-al-review/SKILL.md index 937e06a..a11f891 100644 --- a/skills/bcquality-al-review/SKILL.md +++ b/skills/bcquality-al-review/SKILL.md @@ -64,10 +64,10 @@ plugin-root environment variable, prefer it. file and execute its Source → Relevance → Worklist → Action steps, reading `PLUGIN_ROOT/skills/read.md` and `PLUGIN_ROOT/skills/do.md` on demand. -4. **Emit findings.** Produce the rolled-up findings report in the DO output contract - (`outcome`, `findings`, `references`, `confidence`, `suppressed`). Do not invent a - different shape; downstream consumers parse the DO contract without skill-specific - logic. +4. **Emit findings.** Produce the rolled-up findings report in the DO output contract, + including each review finding's producer-supplied `domain` label (`outcome`, + `findings`, `references`, `confidence`, `suppressed`). Do not invent a different + shape; downstream consumers parse the DO contract without skill-specific logic. If Entry returns `no-match` or `failed`, return the dispatch record unchanged so the caller can log the reason. diff --git a/skills/do.md b/skills/do.md index 777f89f..bc7780e 100644 --- a/skills/do.md +++ b/skills/do.md @@ -95,6 +95,7 @@ Every action skill emits a single JSON document that conforms to this schema: ], "confidence": "high | medium | low", "from-sub-skill": "string", + "domain": "string", "suggested-code": "string", "suggested-code-omission-reason": "string" } @@ -189,6 +190,10 @@ The first reference is the **primary** reference: the knowledge file the finding **`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 cross-cutting reasoning. Absent on findings emitted directly by a leaf skill — including agent findings the leaf emits within its own domain, which appear in the leaf's own report without this field. +**`findings[].domain`** — optional in the shared schema for backward compatibility and for non-review findings. It is a short, human-readable display label for the review domain that produced the finding (for example, `Security`, `Breaking Changes`, `API & Web Services`). A review leaf skill MUST set it on every finding it emits. The value MUST be a non-empty, single-line string with no leading or trailing whitespace or control characters. Internal whitespace, punctuation, case, and non-ASCII characters are valid and significant. + +A review super-skill MUST preserve `domain` verbatim when rolling a leaf finding into its top-level `findings[]`, including preserving its absence from older producers, and MUST set it to `"Agent"` for agent findings it emits about cross-cutting concerns. Consumers MUST tolerate its absence. When rendering a present value, consumers MUST preserve the complete display text, escaping only as required by the output format; they MUST NOT split it on whitespace or restrict it to identifier characters. `domain` is display text, not a stable machine identifier. If a consumer embeds it in metadata or uses it in a deduplication key, it MUST retain the exact string, use a lossless encoding, or use a collision-resistant digest; it MUST NOT rely on lowercasing or lossy slugification as the sole identity. + **`findings[].suggested-code`** — optional in the schema but **expected for mechanical findings**. It is a concrete code-replacement payload for the lines indicated by `location`. When present, the string MUST be a literal replacement for the source lines covered by `location.line` (or `location.range` if set) — i.e., what the file would contain after the fix, with no surrounding diff markers, fences, or commentary. Consumers MAY render it as a one-click suggestion in the delivery surface (for example, a GitHub ```` ```suggestion ```` block). Emit `suggested-code` whenever the fix is small, local, and mechanical: deleting unreachable code; replacing one expression (`Count() > 0` → `not IsEmpty()`); moving a local `Label` to object scope; adding a missing property such as `ToolTip`, `OptionCaption`, or `DataClassification`; replacing a string-concatenated `Error` with a Label-backed call; changing a permission token; or adding a missing `else`/guard branch whose replacement is unambiguous from the surrounding diff. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, prefer adapting the `.good.al` replacement into `suggested-code`. @@ -226,7 +231,7 @@ The five required sections still apply. Their meaning shifts from knowledge file - `## Source` — names the sub-skills invoked (mirrors `sub-skills` in frontmatter). - `## Relevance` — rules for deciding which sub-skills apply to the current task. A sub-skill is relevant when its declared `inputs` are satisfied by the orchestrator's provided inputs and the orchestrator has not disabled it via configuration. The super-skill MUST NOT filter sub-skills by task content (for example, by inspecting the diff or the file). Task-level applicability is the sub-skill's own responsibility; sub-skills signal non-applicability by returning `outcome: "not-applicable"` or `outcome: "no-knowledge"`. - `## Worklist` — the final list of sub-skills to invoke; the rest go to `skipped-sub-skills`. -- `## Action` — invoke each worklisted sub-skill with the appropriate subset of inputs, collect its findings-report verbatim into `sub-results`, and copy its `findings[]` into the super-skill's top-level `findings[]` with `from-sub-skill` set. Findings from a sub-skill with `outcome: "failed"` MUST NOT be copied into the super-skill's top-level `findings[]` and MUST NOT contribute to the super-skill's `summary.counts` (their report is still preserved in `sub-results` for traceability, consistent with DO's rule that consumers ignore a failed skill's findings). +- `## Action` — invoke each worklisted sub-skill with the appropriate subset of inputs, collect its findings-report verbatim into `sub-results`, and copy its `findings[]` into the super-skill's top-level `findings[]` with `from-sub-skill` set. All finding fields, including the optional `domain`, are preserved verbatim unless this contract explicitly requires a transformation. Findings from a sub-skill with `outcome: "failed"` MUST NOT be copied into the super-skill's top-level `findings[]` and MUST NOT contribute to the super-skill's `summary.counts` (their report is still preserved in `sub-results` for traceability, consistent with DO's rule that consumers ignore a failed skill's findings). - `## Output` — the super-skill's output contract, including `sub-results` and, if any, `skipped-sub-skills`. ### Outcome rollup @@ -288,5 +293,3 @@ Conforms to the DO output contract. ## How orchestrators consume output An orchestrator invokes an action skill with an input appropriate to the skill's declared `inputs`, receives the JSON output, and maps findings to its delivery surface (PR comments, build gates, IDE diagnostics). The orchestrator MUST NOT interpret skill-specific fields beyond the schema above. Skills that need richer semantics MUST encode them within the schema (for example, by adding structured `message` text) rather than extending the output shape. - - From 186d8a131465475c79244d994acb872cd5c0d4bf Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Wed, 15 Jul 2026 10:55:25 +0200 Subject: [PATCH 27/86] Complete AL review knowledge readiness (#108) * Complete AL review knowledge readiness Fill telemetry and Query coverage, strengthen thin review domains, correct audited content defects, and add deterministic cheap-model evaluation and reference-integrity safeguards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9825b012-e653-496a-9310-c1f4b6f8ac27 * Generalize review fixture discovery Derive smoke cases from the leaf, domain, and paired-sample conventions so new leaves require no scoring-contract changes. Keep only exceptional selection/context overrides and fail when retrieval metadata cannot rank the selected article. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9825b012-e653-496a-9310-c1f4b6f8ac27 * Preserve published field IDs in sample Keep the existing Email and Contact Email field IDs unchanged, clarify that the sample represents an independent baseline, and use a local breaking-change rule for the generic smoke evaluation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9825b012-e653-496a-9310-c1f4b6f8ac27 * Clarify published field identity rules State explicitly that a published field keeps its ID, name, and type while a replacement is added as a separate field under an unused ID. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9825b012-e653-496a-9310-c1f4b6f8ac27 * Align field obsoletion sample baselines Use Email field ID 3 as the shared baseline so the bad example demonstrates a same-ID rename while the good example retains the original field and adds a separate replacement. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9825b012-e653-496a-9310-c1f4b6f8ac27 --------- Co-authored-by: Jesper Schulz-Wedde --- .github/scripts/validate_frontmatter.py | 34 +- .github/workflows/review-fixtures.yml | 18 + README.md | 16 +- evaluation/README.md | 56 ++ evaluation/review-fixtures.json | 39 ++ .../object-affixes-prevent-collisions.md | 6 +- ...cover-setup-and-usage-without-super.bad.al | 43 ++ ...over-setup-and-usage-without-super.good.al | 45 ++ ...ets-cover-setup-and-usage-without-super.md | 26 + ...ct-affix-not-extension-member-affix.bad.al | 22 + ...t-affix-not-extension-member-affix.good.al | 22 + ...object-affix-not-extension-member-affix.md | 26 + ...s-part-of-published-object-identity.bad.al | 9 + ...-part-of-published-object-identity.good.al | 8 + ...ce-is-part-of-published-object-identity.md | 26 + ...ble-fields-instead-of-deleting-them.bad.al | 5 +- ...le-fields-instead-of-deleting-them.good.al | 6 +- ...e-table-fields-instead-of-deleting-them.md | 6 +- ...ms-with-insert-not-field-assignment.bad.al | 26 + ...s-with-insert-not-field-assignment.good.al | 29 ++ ...-items-with-insert-not-field-assignment.md | 26 + ...xtensions-are-additive-and-top-down.bad.al | 35 ++ ...tensions-are-additive-and-top-down.good.al | 37 ++ ...on-extensions-are-additive-and-top-down.md | 26 + .../fielderror-default-message-logic.md | 6 +- .../error-handling/fielderror-vs-testfield.md | 4 + ...yfunction-return-disables-try-semantics.md | 4 + ...-still-calculate-before-bc26-opt-in.bad.al | 18 + ...still-calculate-before-bc26-opt-in.good.al | 29 ++ ...elds-still-calculate-before-bc26-opt-in.md | 26 + ...unction-for-error-catching-not-rollback.md | 10 +- .../avoid-strsubstno-prebuild-before-error.md | 2 +- ...ct-substitution-safe-for-telemetry.good.al | 10 - ...-direct-substitution-safe-for-telemetry.md | 24 - .../no-pii-in-telemetry-message-string.bad.al | 6 +- ...no-pii-in-telemetry-message-string.good.al | 4 +- ...message-requires-dataclassification.bad.al | 2 +- ...essage-requires-dataclassification.good.al | 2 +- ...ery-resets-cursor-but-keeps-filters.bad.al | 28 + ...ry-resets-cursor-but-keeps-filters.good.al | 39 ++ ...g-query-resets-cursor-but-keeps-filters.md | 26 + .../set-query-filters-before-open.bad.al | 30 ++ .../set-query-filters-before-open.good.al | 31 ++ .../query/set-query-filters-before-open.md | 26 + ...every-field-with-dataclassification.bad.al | 28 - ...very-field-with-dataclassification.good.al | 36 -- ...ify-every-field-with-dataclassification.md | 26 - ...l-access-is-not-a-security-boundary.bad.al | 15 + ...-access-is-not-a-security-boundary.good.al | 39 ++ ...ernal-access-is-not-a-security-boundary.md | 26 + .../security/secrets-isolated-storage.md | 4 + .../security/secrettext-for-credentials.md | 2 +- .../security/secrettext-with-httpclient.md | 2 +- .../caption-required-on-page-fields.bad.al | 25 +- .../caption-required-on-page-fields.good.al | 33 +- .../style/temporary-variable-temp-prefix.md | 4 +- .../choose-telemetry-scope-by-audience.bad.al | 26 + ...choose-telemetry-scope-by-audience.good.al | 24 + .../choose-telemetry-scope-by-audience.md | 26 + ...feature-uptake-transitions-in-order.bad.al | 11 + ...eature-uptake-transitions-in-order.good.al | 26 + .../feature-uptake-transitions-in-order.md | 26 + .../feature-usage-only-after-success.bad.al | 23 + .../feature-usage-only-after-success.good.al | 27 + .../feature-usage-only-after-success.md | 26 + ...keep-custom-dimension-schema-stable.bad.al | 14 + ...eep-custom-dimension-schema-stable.good.al | 14 + .../keep-custom-dimension-schema-stable.md | 26 + .../match-verbosity-to-signal-severity.bad.al | 24 + ...match-verbosity-to-signal-severity.good.al | 24 + .../match-verbosity-to-signal-severity.md | 26 + ...-one-telemetry-logger-per-publisher.bad.al | 37 ++ ...one-telemetry-logger-per-publisher.good.al | 18 + ...ster-one-telemetry-logger-per-publisher.md | 26 + .../telemetry-event-id-stable-unique.bad.al | 2 +- .../telemetry-event-id-stable-unique.good.al | 2 +- .../telemetry-event-id-stable-unique.md | 4 +- ...ts-must-lower-the-execution-context.bad.al | 26 + ...s-must-lower-the-execution-context.good.al | 43 ++ ...-tests-must-lower-the-execution-context.md | 26 + ...solation-belongs-on-the-test-runner.bad.al | 22 + ...olation-belongs-on-the-test-runner.good.al | 22 + ...estisolation-belongs-on-the-test-runner.md | 26 + .../knowledge/ui/page-background-tasks.md | 2 +- ...o-not-block-upgrade-on-data-errors.good.al | 2 +- .../skills/review/al-appsource-review.md | 12 +- .../review/al-breaking-changes-review.md | 16 +- microsoft/skills/review/al-code-review.md | 8 +- .../skills/review/al-data-modeling-review.md | 6 +- .../skills/review/al-error-handling-review.md | 11 +- microsoft/skills/review/al-events-review.md | 2 +- .../skills/review/al-interfaces-review.md | 4 +- .../skills/review/al-performance-review.md | 7 +- microsoft/skills/review/al-privacy-review.md | 7 +- microsoft/skills/review/al-query-review.md | 56 ++ microsoft/skills/review/al-security-review.md | 11 +- microsoft/skills/review/al-style-review.md | 7 + .../skills/review/al-telemetry-review.md | 15 +- microsoft/skills/review/al-testing-review.md | 10 +- microsoft/skills/review/al-upgrade-review.md | 2 +- .../skills/review/al-web-services-review.md | 2 +- skills/bcquality-al-review/SKILL.md | 8 + skills/do.md | 11 + skills/write.md | 3 + tools/Test-ReviewFixtures.ps1 | 483 ++++++++++++++++++ 105 files changed, 2229 insertions(+), 212 deletions(-) create mode 100644 .github/workflows/review-fixtures.yml create mode 100644 evaluation/README.md create mode 100644 evaluation/review-fixtures.json create mode 100644 microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.bad.al create mode 100644 microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.good.al create mode 100644 microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md create mode 100644 microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al create mode 100644 microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al create mode 100644 microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md create mode 100644 microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.bad.al create mode 100644 microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.good.al create mode 100644 microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md create mode 100644 microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.bad.al create mode 100644 microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.good.al create mode 100644 microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.md create mode 100644 microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.bad.al create mode 100644 microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.good.al create mode 100644 microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.md create mode 100644 microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.bad.al create mode 100644 microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.good.al create mode 100644 microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.md delete mode 100644 microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al delete mode 100644 microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md create mode 100644 microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.bad.al create mode 100644 microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.good.al create mode 100644 microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md create mode 100644 microsoft/knowledge/query/set-query-filters-before-open.bad.al create mode 100644 microsoft/knowledge/query/set-query-filters-before-open.good.al create mode 100644 microsoft/knowledge/query/set-query-filters-before-open.md delete mode 100644 microsoft/knowledge/security/classify-every-field-with-dataclassification.bad.al delete mode 100644 microsoft/knowledge/security/classify-every-field-with-dataclassification.good.al delete mode 100644 microsoft/knowledge/security/classify-every-field-with-dataclassification.md create mode 100644 microsoft/knowledge/security/internal-access-is-not-a-security-boundary.bad.al create mode 100644 microsoft/knowledge/security/internal-access-is-not-a-security-boundary.good.al create mode 100644 microsoft/knowledge/security/internal-access-is-not-a-security-boundary.md create mode 100644 microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.bad.al create mode 100644 microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.good.al create mode 100644 microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md create mode 100644 microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.bad.al create mode 100644 microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.good.al create mode 100644 microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md create mode 100644 microsoft/knowledge/telemetry/feature-usage-only-after-success.bad.al create mode 100644 microsoft/knowledge/telemetry/feature-usage-only-after-success.good.al create mode 100644 microsoft/knowledge/telemetry/feature-usage-only-after-success.md create mode 100644 microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.bad.al create mode 100644 microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.good.al create mode 100644 microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md create mode 100644 microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.bad.al create mode 100644 microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.good.al create mode 100644 microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md create mode 100644 microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.bad.al create mode 100644 microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.good.al create mode 100644 microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md rename microsoft/knowledge/{style => telemetry}/telemetry-event-id-stable-unique.bad.al (89%) rename microsoft/knowledge/{style => telemetry}/telemetry-event-id-stable-unique.good.al (88%) rename microsoft/knowledge/{style => telemetry}/telemetry-event-id-stable-unique.md (98%) create mode 100644 microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.bad.al create mode 100644 microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.good.al create mode 100644 microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md create mode 100644 microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.bad.al create mode 100644 microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.good.al create mode 100644 microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md create mode 100644 microsoft/skills/review/al-query-review.md create mode 100644 tools/Test-ReviewFixtures.ps1 diff --git a/.github/scripts/validate_frontmatter.py b/.github/scripts/validate_frontmatter.py index 682a801..dd3ef4e 100644 --- a/.github/scripts/validate_frontmatter.py +++ b/.github/scripts/validate_frontmatter.py @@ -62,6 +62,7 @@ ISO_ALPHA2 = re.compile(r"^[a-z]{2}$") RANGE_SHORTHAND = re.compile(r"^(\d+)\.\.(\d+)?$") FENCED_CODE_BLOCK = re.compile(r"^```", re.MULTILINE) HEADING_H2 = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) +SAMPLE_REFERENCE = re.compile(r"`([a-z0-9]+(?:-[a-z0-9]+)*\.(?:good|bad)\.[a-z0-9]+)`") # --- Diagnostics ------------------------------------------------------------ @@ -222,6 +223,13 @@ def validate_knowledge(path: Path, parsed: Parsed, report: Report) -> None: if "domain" in fm: if not isinstance(fm["domain"], str) or not fm["domain"].strip(): report.error(path, "R04", "domain must be a non-empty string", 1) + elif fm["domain"] != path.parent.name: + report.error( + path, + "R27", + f"frontmatter domain '{fm['domain']}' must match directory '{path.parent.name}'", + 1, + ) # R05 keywords if "keywords" in fm: @@ -477,7 +485,16 @@ def validate_samples_in_domain(domain_dir: Path, root: Path, report: Report) -> """R14: every non-.md file must match .. with .md present.""" if not domain_dir.is_dir(): return - article_slugs = {p.stem for p in domain_dir.glob("*.md")} + articles = {p.stem: p for p in domain_dir.glob("*.md")} + article_slugs = set(articles) + article_texts: dict[str, str] = {} + for slug, article in articles.items(): + try: + article_texts[slug] = article.read_text(encoding="utf-8") + except UnicodeDecodeError: + # R01 reports this during the article pass. + continue + for entry in domain_dir.iterdir(): if not entry.is_file() or entry.suffix == ".md": continue @@ -491,9 +508,24 @@ def validate_samples_in_domain(domain_dir: Path, root: Path, report: Report) -> kind = m.group("kind") if slug not in article_slugs: report.error(entry, "R14", f"orphan sample: no matching article '{slug}.md' in {domain_dir.relative_to(root).as_posix()}") + elif entry.name not in article_texts.get(slug, ""): + report.error( + entry, + "R28", + f"sample is not referenced by its article '{slug}.md'", + ) if kind not in VALID_SAMPLE_KINDS: report.warn(entry, "R14", f"non-standard sample kind '{kind}'; standard kinds are {sorted(VALID_SAMPLE_KINDS)}") + for slug, article in articles.items(): + for sample_name in SAMPLE_REFERENCE.findall(article_texts.get(slug, "")): + if not (domain_dir / sample_name).is_file(): + report.error( + article, + "R28", + f"referenced sample does not exist: '{sample_name}'", + ) + # --- Orchestration ---------------------------------------------------------- diff --git a/.github/workflows/review-fixtures.yml b/.github/workflows/review-fixtures.yml new file mode 100644 index 0000000..fff9cd0 --- /dev/null +++ b/.github/workflows/review-fixtures.yml @@ -0,0 +1,18 @@ +name: Validate AL review fixtures + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + validate-review-fixtures: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Validate review evaluation corpus + shell: pwsh + run: ./tools/Test-ReviewFixtures.ps1 -Root . -PrepareDirectory "$env:RUNNER_TEMP/bcquality-review-fixtures" diff --git a/README.md b/README.md index 6abf98a..3b09ff7 100644 --- a/README.md +++ b/README.md @@ -88,18 +88,9 @@ Code examples belong in separate files, not in the knowledge file itself. Knowle ## Scope -BCQuality covers Business Central broadly — the application domains it supports, the technologies used to extend it, and the practices that keep implementations healthy. The scope includes: +The current curated corpus is focused on **technical AL code review**: AppSource and compatibility, data modeling, error handling, events, interfaces, performance, privacy, Query objects, security, style, telemetry, testing, UI, upgrade, and web services. These are the domains backed by knowledge files and registered review leaves today. -- **Business Central domains** — Finance, Supply Chain Management, Manufacturing, Jobs, Warehousing, Service, and the many other functional areas BC covers. Domain knowledge helps agents understand the business context they are working in. -- AL language patterns and anti-patterns -- PowerShell scripting for BC -- Pipelines (AL-Go, GitHub Actions) -- Business Central APIs -- Power Platform integration -- Telemetry and KQL -- AppSource lifecycle - -A BC developer's actual job spans all of this, and BCQuality reflects that. +Business Central functional domains (Finance, Supply Chain Management, Manufacturing, Jobs, Warehousing, Service), PowerShell, pipelines, and Power Platform remain valid future repository scope, but they are **not current coverage claims** until corresponding knowledge and action skills exist. Consumers should derive supported review scope from the live knowledge index and dispatched skills, not from roadmap breadth. ## How agents consume BCQuality @@ -122,6 +113,7 @@ For the end-to-end flow — from orchestrator trigger through to how output reac ``` ├── /skills/ # Global: entry-point skill + meta-skill contracts (READ, DO, WRITE) +├── /evaluation/ # Neutral good/bad review fixtures and scoring contract ├── /.github/ # Actions and workflows ├── /microsoft/ # Microsoft-endorsed layer │ ├── /knowledge/ # Knowledge files by domain @@ -158,6 +150,8 @@ Contributions are welcome. Before submitting a PR: CI runs validation on every PR. If your knowledge file has schema violations, missing sections, code blocks, or exceeds 100 lines, the check will fail with a clear error message. +Companion samples must be referenced by filename from their article, and every referenced sample must exist. The review evaluation corpus under [`evaluation/`](evaluation/) adds one positive and one clean control for every registered AL review leaf; see [`evaluation/README.md`](evaluation/README.md) for credential-free validation and optional fast-model scoring. + ## License [MIT](LICENSE) diff --git a/evaluation/README.md b/evaluation/README.md new file mode 100644 index 0000000..cd25d3d --- /dev/null +++ b/evaluation/README.md @@ -0,0 +1,56 @@ +# AL review evaluation + +The evaluation is convention-driven. For every `microsoft/skills/review/al--review.md` leaf, the harness finds `microsoft/knowledge//`, selects the first article (by filename) with both `.bad.al` and `.good.al` companions, and derives the expected positive and clean control automatically. Adding a conforming leaf requires no scoring-contract edit. + +`review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different article or add context when the generic convention cannot express a scenario. It should remain empty in the normal case. + +Model-facing preparation hashes case IDs, neutralizes `Good`/`Bad` object-name tokens, and removes full-line sample comments so neither the article slug, domain, nor expected outcome reveals the answer. + +## Validate the corpus + +```powershell +pwsh ./tools/Test-ReviewFixtures.ps1 -Root . +``` + +This credential-free check proves every registered leaf maps to a same-named knowledge domain with at least one complete AL sample pair and that all configured overrides are valid. + +## Run a fast-model evaluation + +1. Prepare neutral inputs: + + ```powershell + pwsh ./tools/Test-ReviewFixtures.ps1 -Root . -PrepareDirectory ./.evaluation-run + ``` + + This is also the CI path. It derives all cases, builds the current index, requires the convention-selected article to rank naturally into the candidate cutoff, and prepares the neutral requests. + +2. For a fast/small model, use one fresh invocation per `request-case-*.json`. Each request embeds the exact leaf instructions, that domain's candidate index rows with authoritative paths, and one opaque case. The model opens only matching articles and copies finding IDs from `candidateArticles[].path`. Save each response with the matching `result-case-*.json` name in the same directory. + + `request-.json` files provide optional two-case leaf batches; save those as `result-.json`. Directory scoring prefers `result-case-*.json` when present and otherwise falls back to `result-*.json`. `review-request.json` is an optional all-domains stress test for larger models. Neither batch form is the preferred fast-model profile. + +3. Save only this result shape: + + ```json + { + "cases": [ + { + "id": "case-a1b2c3d4", + "findings": [ + { "id": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md" } + ] + } + ] + } + ``` + + Include every case. A clean control has an empty `findings` array. + +4. Score all per-leaf results together: + + ```powershell + pwsh ./tools/Test-ReviewFixtures.ps1 -Root . -ResultsDirectory ./.evaluation-run + ``` + + For a single combined stress-test result, use `-ResultsPath` instead. + +The committed gate requires full expected recall, the exact convention-derived article ID, and no findings on clean controls. diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json new file mode 100644 index 0000000..2f85d5c --- /dev/null +++ b/evaluation/review-fixtures.json @@ -0,0 +1,39 @@ +{ + "version": 2, + "selection": "first-paired-al-article", + "minimumExpectedRecall": 1.0, + "minimumCleanRate": 1.0, + "overrides": { + "appsource": { + "context": "AppSourceCop mandatoryAffixes is configured to ABC." + }, + "breaking-changes": { + "article": "do-not-expose-sensitive-data-through-public-api" + }, + "events": { + "article": "initialize-ishandled-to-false-before-publishing" + }, + "interfaces": { + "article": "set-defaultimplementation-on-enum" + }, + "performance": { + "article": "use-isempty-for-existence-check" + }, + "privacy": { + "article": "no-pii-in-telemetry-message-string" + }, + "style": { + "article": "label-comment-explains-placeholders" + }, + "telemetry": { + "article": "telemetry-event-id-stable-unique" + }, + "upgrade": { + "article": "initvalue-does-not-update-existing-rows", + "context": "The extended table existed in the previous app version and already contains rows." + }, + "web-services": { + "article": "expose-systemid-as-the-api-key" + } + } +} diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md index 49ef80c..a46c0d1 100644 --- a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md +++ b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md @@ -11,18 +11,18 @@ application-area: [all] ## Description -An AppSource extension must carry a reserved affix — a prefix or a suffix of at least three characters — on the names of the objects it owns **and** on any field, key, control, or action it adds to a base-application object. The affix is registered with Microsoft; when two coexisting extensions would otherwise collide, the registrant of the affix wins. Without it, two apps that both add a `Loyalty Points` field to `Customer`, or both define a `Loyalty Tier` table, cannot be installed side by side. +An AppSource extension must prevent name collisions through its registered affix or, on BC23 and later for objects it owns, a namespace with at least two levels. The affix still applies to every field, key, control, or action added to a base-application object; see `two-level-namespace-replaces-object-affix-not-extension-member-affix.md`. Without either mechanism, two apps that both define a `Loyalty Tier` table cannot coexist, and two apps that add an unaffixed `Loyalty Points` field to `Customer` still collide regardless of their namespaces. AppSourceCop enforces this. The primary rule is AS0011 ("An affix is required"); the affixes are configured through `mandatoryAffixes` (and `mandatoryPrefix`) in `AppSourceCop.json`. Two placements matter and are easy to get half-right: an object you define carries the affix at **object-name** level, while a member you add to a **standard** object carries the affix on that **member's** name. Adding an affixed object is not enough — an unaffixed field bolted onto `Customer` still collides and still fails validation. ## Best Practice -Own objects are named with the affix (e.g. a table `ABC Loyalty Tier`), and every field or action added to a standard object is individually affixed (e.g. `Loyalty Points ABC` on a `Customer` tableextension). +Own objects use the registered affix (for example `ABC Loyalty Tier`) or, when targeting BC23 or later, a qualifying namespace. Every field or action added to a standard object remains individually affixed (for example `Loyalty Points ABC` on a `Customer` tableextension). See sample: `object-affixes-prevent-collisions.good.al`. ## Anti Pattern -Unaffixed object or member names, or the common half-measure: the extension object carries the affix but a field it adds to a standard table does not. AS0011 flags the missing affix and the field can still collide with another app. +An owned object with neither a qualifying namespace nor an affix, an unaffixed extension member, or the common half-measure where the extension object carries the affix but a field it adds to a standard table does not. AS0011 flags the missing collision protection and the field can still collide with another app. See sample: `object-affixes-prevent-collisions.bad.al`. diff --git a/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.bad.al b/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.bad.al new file mode 100644 index 0000000..606bef0 --- /dev/null +++ b/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.bad.al @@ -0,0 +1,43 @@ +table 50476 "Rental Setup Bad" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Primary Key"; Code[10]) { } + } +} + +page 50477 "Rental Setup Bad" +{ + PageType = Card; + SourceTable = "Rental Setup Bad"; + + layout + { + area(Content) + { + field("Primary Key"; Rec."Primary Key") + { + ApplicationArea = All; + Caption = 'Primary Key'; + ToolTip = 'Specifies the setup record.'; + } + } + } +} + +codeunit 50478 "Rental Setup Mgt. Bad" +{ + procedure Initialize() + begin + end; +} + +permissionset 50479 "Rental User" +{ + Assignable = true; + // The setup page opens, but saving or running setup logic requires SUPER. + Permissions = + page "Rental Setup Bad" = X; +} diff --git a/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.good.al b/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.good.al new file mode 100644 index 0000000..31b2d3e --- /dev/null +++ b/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.good.al @@ -0,0 +1,45 @@ +table 50472 "Rental Setup" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Primary Key"; Code[10]) { } + } +} + +page 50473 "Rental Setup" +{ + PageType = Card; + SourceTable = "Rental Setup"; + + layout + { + area(Content) + { + field("Primary Key"; Rec."Primary Key") + { + ApplicationArea = All; + Caption = 'Primary Key'; + ToolTip = 'Specifies the setup record.'; + } + } + } +} + +codeunit 50474 "Rental Setup Mgt." +{ + procedure Initialize() + begin + end; +} + +permissionset 50475 "Rental Manager" +{ + Assignable = true; + Permissions = + tabledata "Rental Setup" = RIMD, + table "Rental Setup" = X, + page "Rental Setup" = X, + codeunit "Rental Setup Mgt." = X; +} diff --git a/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md b/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md new file mode 100644 index 0000000..ca89003 --- /dev/null +++ b/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: appsource +keywords: [permission-set, super, appsource, setup, usage, tabledata, execute, submission] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AppSource permission sets must cover setup and usage without SUPER + +## Description + +An AppSource app must provide permission sets that let assigned users complete the app's setup and normal usage without `SUPER`. The requirement is about complete effective grants, not about naming the permission set after the app. A package can compile and install with missing tabledata or execute permissions, then fail only when Marketplace validation or a real non-SUPER user reaches the omitted path. + +## Best Practice + +Trace every setup page, normal page, report, codeunit, and tabledata operation exposed by the app and cover it through assignable role permission sets composed from focused non-assignable sets. Validate setup and representative workflows as a user assigned only those app roles. Grant the minimum required operations; completeness is not a reason to use wildcards. + +See sample: `permission-sets-cover-setup-and-usage-without-super.good.al`. + +## Anti Pattern + +Shipping no permission set, omitting a tabledata or execute grant used by the app's own UI, or instructing users and validators to assign `SUPER` when setup fails. Do not flag a permission-set name that differs from the app name; no such naming requirement exists. + +See sample: `permission-sets-cover-setup-and-usage-without-super.bad.al`. diff --git a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al new file mode 100644 index 0000000..de2d3ed --- /dev/null +++ b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al @@ -0,0 +1,22 @@ +namespace Contoso; + +table 50462 "Rental Agreement" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { } + } +} + +tableextension 50463 "Rental Customer Ext" extends Customer +{ + fields + { + field(50463; "Loyalty Points"; Integer) + { + DataClassification = CustomerContent; + } + } +} diff --git a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al new file mode 100644 index 0000000..93fa9c6 --- /dev/null +++ b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al @@ -0,0 +1,22 @@ +namespace Contoso.Rentals; + +table 50460 "Rental Agreement" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { } + } +} + +tableextension 50461 "Rental Customer Ext" extends Customer +{ + fields + { + field(50461; "Loyalty Points RNT"; Integer) + { + DataClassification = CustomerContent; + } + } +} diff --git a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md new file mode 100644 index 0000000..509fe6f --- /dev/null +++ b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md @@ -0,0 +1,26 @@ +--- +bc-version: [23..] +domain: appsource +keywords: [namespace, two-level, affix, prefix, suffix, as0011, tableextension, pageextension] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A two-level namespace replaces an object affix, not an extension-member affix + +## Description + +Current AppSource naming guidance accepts a namespace with at least two levels, such as `Contoso.Rentals`, instead of a registered prefix or suffix on the names of objects the app owns. The namespace does not qualify members added to another publisher's object: fields, keys, controls, and actions introduced through table or page extensions still share the target object's flat member namespace and still need the registered affix. + +## Best Practice + +Choose one collision strategy for owned objects: a registered affix or a globally meaningful namespace with at least two levels. Regardless of that choice, apply the registered affix to every member added to a base or third-party object. Keep the affix configured for AppSourceCop so member validation remains deterministic. + +See sample: `two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al`. + +## Anti Pattern + +Using `namespace Contoso;` as though one level satisfied the AppSource alternative, or declaring `namespace Contoso.Rentals;` and then adding an unaffixed `Loyalty Points` field to `Customer`. The namespace distinguishes the extension's own objects; it cannot disambiguate members on Customer. + +See sample: `two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al`. diff --git a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.bad.al b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.bad.al new file mode 100644 index 0000000..e2a5152 --- /dev/null +++ b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.bad.al @@ -0,0 +1,9 @@ +// This published object previously used namespace Contoso.Rentals. +namespace Contoso.RentalManagement; + +codeunit 50467 "Rental Agreement Mgt." +{ + procedure CreateAgreement() + begin + end; +} diff --git a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.good.al b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.good.al new file mode 100644 index 0000000..ea151a8 --- /dev/null +++ b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.good.al @@ -0,0 +1,8 @@ +namespace Contoso.Rentals; + +codeunit 50466 "Rental Agreement Mgt." +{ + procedure CreateAgreement() + begin + end; +} diff --git a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md new file mode 100644 index 0000000..fde0f54 --- /dev/null +++ b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md @@ -0,0 +1,26 @@ +--- +bc-version: [23..] +domain: breaking-changes +keywords: [namespace, published-object, dependency, breaking-change, as0007, compile-time-identity] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Treat a published namespace as part of object identity + +## Description + +AL resolves an object by namespace and name. Once an app ships and dependent extensions compile against that identity, changing the namespace breaks their references even when the object name and ID stay unchanged. AppSourceCop AS0007 rejects changing the namespace of published objects; namespaces are therefore not a cosmetic folder-like label that can be reorganized after release. + +## Best Practice + +Choose a globally meaningful namespace before first publication and keep it stable. Add new functional areas beneath that structure without moving existing published objects. If an identity must move, use the platform's supported move/obsoletion lifecycle rather than a source-only namespace rename. + +See sample: `namespace-is-part-of-published-object-identity.good.al`. + +## Anti Pattern + +Changing `namespace Contoso.Rentals;` to `namespace Contoso.RentalManagement;` as a cleanup while leaving the object name and ID untouched. Every dependent `using` directive and qualified reference targets the old identity and stops compiling. + +See sample: `namespace-is-part-of-published-object-identity.bad.al`. diff --git a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.bad.al b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.bad.al index 1277fc2..d5f51cf 100644 --- a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.bad.al +++ b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.bad.al @@ -3,9 +3,10 @@ table 50311 "Customer Profile Bad" fields { field(1; "No."; Code[20]) { } - // Breaking: the published field was renamed while retaining ID 2. + // Breaking: the published Email field at ID 3 was renamed while retaining + // the ID. The good example keeps Email at ID 3 and adds a separate field. // AppSourceCop AS0005 rejects the compatibility change; retaining the ID // does not by itself mean the stored column was dropped and re-created. - field(2; "Contact Email"; Text[80]) { } + field(3; "Contact Email"; Text[80]) { } } } diff --git a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.good.al b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.good.al index ed239d2..d0ce8d2 100644 --- a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.good.al +++ b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.good.al @@ -3,10 +3,10 @@ table 50310 "Customer Profile Good" fields { field(1; "No."; Code[20]) { } - // Replacement field shipped alongside the old one. + // Replacement is a separate field under an otherwise unused ID. field(2; "Contact Email"; Text[80]) { } - // Old field kept and marked Pending so dependent code keeps compiling and - // an upgrade codeunit can copy its data before it is finally removed. + // Old field keeps its original ID, name, and type and is marked Pending so + // dependent code keeps compiling while an upgrade codeunit migrates its data. field(3; "Email"; Text[80]) { ObsoleteState = Pending; diff --git a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md index 0d5a289..3ff6474 100644 --- a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md +++ b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md @@ -7,7 +7,7 @@ countries: [w1] application-area: [all] --- -# Obsolete published table fields instead of deleting or renumbering them +# Obsolete published table fields instead of deleting, renaming, or renumbering them ## Description @@ -15,12 +15,12 @@ A shipped table field carries both a source-level contract and persisted data. R ## Best Practice -Add the replacement field under a new ID, then mark the old field `ObsoleteState = Pending` with an `ObsoleteReason` that names the replacement and an `ObsoleteTag` recording the obsoletion version. Keep the old field readable so an upgrade codeunit can copy its data during the deprecation window. Move it to `ObsoleteState = Removed` only in a later release, after the window has passed and data has migrated. +Keep the old field's ID, name, and type unchanged. Add the replacement as a separate field under an unused ID, then mark the old field `ObsoleteState = Pending` with an `ObsoleteReason` that names the replacement and an `ObsoleteTag` recording the obsoletion version. Keep the old field readable so an upgrade codeunit can copy its data during the deprecation window. Move it to `ObsoleteState = Removed` only in a later release, after the window has passed and data has migrated. See sample: `obsolete-table-fields-instead-of-deleting-them.good.al`. ## Anti Pattern -Renaming published `Email` to `Contact Email` with the same ID violates the compatibility contract and AS0005, even though the retained ID does not itself imply a fresh empty column. Deleting `Email` or moving the replacement to another ID without migration additionally risks losing its stored values. Detection: a previously shipped field removed, renumbered, or renamed with no retained `Pending` field and migration path. +Renaming published `Email` to `Contact Email` with the same ID violates the compatibility contract and AS0005, even though the retained ID does not itself imply a fresh empty column. Deleting `Email` or changing its ID additionally risks losing its stored values. Detection: any previously shipped field whose name changes at the same ID, or whose original ID disappears without the unchanged field being retained as `Pending` and its data migrated to a separate replacement field. See sample: `obsolete-table-fields-instead-of-deleting-them.bad.al`. diff --git a/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.bad.al b/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.bad.al new file mode 100644 index 0000000..9805674 --- /dev/null +++ b/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.bad.al @@ -0,0 +1,26 @@ +table 50441 "Source Media Bad" +{ + fields + { + field(1; Code; Code[20]) { } + field(10; Pictures; MediaSet) { } + } +} + +table 50442 "Target Media Bad" +{ + fields + { + field(1; Code; Code[20]) { } + field(20; Pictures; MediaSet) { } + } +} + +codeunit 50443 "Share Media Bad" +{ + procedure CopyPictures(Source: Record "Source Media Bad"; var Target: Record "Target Media Bad") + begin + Target.Pictures := Source.Pictures; + Target.Modify(true); + end; +} diff --git a/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.good.al b/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.good.al new file mode 100644 index 0000000..fc78c6f --- /dev/null +++ b/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.good.al @@ -0,0 +1,29 @@ +table 50438 "Source Media Good" +{ + fields + { + field(1; Code; Code[20]) { } + field(10; Pictures; MediaSet) { } + } +} + +table 50439 "Target Media Good" +{ + fields + { + field(1; Code; Code[20]) { } + field(20; Pictures; MediaSet) { } + } +} + +codeunit 50440 "Share Media Good" +{ + procedure CopyPictures(Source: Record "Source Media Good"; var Target: Record "Target Media Good") + var + Index: Integer; + begin + for Index := 1 to Source.Pictures.Count() do + Target.Pictures.Insert(Source.Pictures.Item(Index)); + Target.Modify(true); + end; +} diff --git a/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.md b/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.md new file mode 100644 index 0000000..10946a5 --- /dev/null +++ b/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [mediaset, media, insert, field-assignment, tenant-media, delete-integrity, sharing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Share MediaSet items with Insert instead of field assignment + +## Description + +`Media` and `MediaSet` fields store IDs that reference tenant media system tables. When a record is deleted, the runtime looks for other references only in the same table and field index; it does not scan every table. Directly assigning a media-set field between different table types copies the ID without registering a separate media-set reference, so deleting one record can remove media that the other record still appears to reference. + +## Best Practice + +When sharing media between different tables, iterate the source `MediaSet` and call `Target.MediaSetField.Insert(Source.MediaSetField.Item(Index))`, then modify the target record. Direct field assignment is safe only when source and target are the same record subtype and use the same field ID. This concern is about reference/delete integrity, not the separate performance cost of `ModifyAll` on tables with media fields. + +See sample: `share-mediaset-items-with-insert-not-field-assignment.good.al`. + +## Anti Pattern + +`Target.Picture := Source.Picture;` where the two variables refer to different table types or different media-field IDs. The code copies an opaque ID, but the platform does not know that two independent fields now share the media object. + +See sample: `share-mediaset-items-with-insert-not-field-assignment.bad.al`. diff --git a/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.bad.al b/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.bad.al new file mode 100644 index 0000000..694575f --- /dev/null +++ b/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.bad.al @@ -0,0 +1,35 @@ +enum 50434 "Relation Type Bad" +{ + Extensible = true; + + value(0; Customer) { } +} + +table 50435 "Related Entity Bad" +{ + fields + { + field(1; Type; Enum "Relation Type Bad") { } + field(2; "Related No."; Code[20]) + { + // This unconditional relation wins before extension branches run. + TableRelation = Customer; + } + } +} + +enumextension 50436 "Relation Type Bad Ext" extends "Relation Type Bad" +{ + value(10; Resource) { } +} + +tableextension 50437 "Related Entity Bad Ext" extends "Related Entity Bad" +{ + fields + { + modify("Related No.") + { + TableRelation = if (Type = const(Resource)) Resource; + } + } +} diff --git a/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.good.al b/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.good.al new file mode 100644 index 0000000..1f8a8a2 --- /dev/null +++ b/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.good.al @@ -0,0 +1,37 @@ +enum 50430 "Relation Type Good" +{ + Extensible = true; + + value(0; Customer) { } + value(1; Item) { } +} + +table 50431 "Related Entity Good" +{ + fields + { + field(1; Type; Enum "Relation Type Good") { } + field(2; "Related No."; Code[20]) + { + TableRelation = + if (Type = const(Customer)) Customer + else if (Type = const(Item)) Item; + } + } +} + +enumextension 50432 "Relation Type Resource" extends "Relation Type Good" +{ + value(10; Resource) { } +} + +tableextension 50433 "Related Entity Resource" extends "Related Entity Good" +{ + fields + { + modify("Related No.") + { + TableRelation = if (Type = const(Resource)) Resource; + } + } +} diff --git a/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.md b/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.md new file mode 100644 index 0000000..1908f82 --- /dev/null +++ b/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [tablerelation, tableextension, enumextension, additive, top-down, unconditional-relation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Design TableRelation branches for additive top-down extension + +## Description + +A `tableextension` can add to an existing `TableRelation`, but the combined relation is evaluated top-down after the original value. The first unconditional relation wins. An extension branch appended after an unconditional base relation is therefore unreachable, even though the extension compiles and appears to describe the new enum value correctly. + +## Best Practice + +When a relation is designed to follow an extensible enum, express the base cases as conditional branches and leave no unconditional catch-all ahead of future extension branches. An enum extension can then append a condition for its new value. When extending a field you do not own, inspect the original `TableRelation`; do not claim that an appended condition overrides an unconditional relation. + +See sample: `table-relation-extensions-are-additive-and-top-down.good.al`. + +## Anti Pattern + +A base field has an unconditional `TableRelation = Customer;` and a `tableextension` adds `if (Type = const(Resource)) Resource`. The original unconditional branch always wins, so the new enum value still validates and looks up against Customer. The concern is evaluation order, not `ValidateTableRelation`; free-form input is covered separately by security guidance. + +See sample: `table-relation-extensions-are-additive-and-top-down.bad.al`. diff --git a/microsoft/knowledge/error-handling/fielderror-default-message-logic.md b/microsoft/knowledge/error-handling/fielderror-default-message-logic.md index 578092f..c02bb4f 100644 --- a/microsoft/knowledge/error-handling/fielderror-default-message-logic.md +++ b/microsoft/knowledge/error-handling/fielderror-default-message-logic.md @@ -14,5 +14,9 @@ application-area: [all] ## Best Practice For a plain required-field check, prefer `TestField`, which tests the condition and raises the error in one call. When the condition is non-trivial and has already been evaluated, call `FieldError(FieldNo)` with no message to get the localized default (`must have a value`, `is not valid`, etc.), or pass a short lowercase predicate such as `FieldError(FieldNo, 'must be a positive number')`. Start the custom text with a lowercase letter so it reads as one sentence with the auto-inserted caption, and use a field-number reference (or the field token) rather than a hard-coded field name so captions and translations stay correct. Let the framework supply the caption, value, table, and key context for you. +See sample: `fielderror-default-message-logic.good.al`. + ## Anti Pattern -Re-testing a condition you already evaluated, or passing a fully formed sentence like `'The Amount field must be positive.'` to `FieldError`. The result reads as `Amount The Amount field must be positive. in Gen. Journal Line ...` — capital letter mid-sentence, caption and value repeated, and a stray trailing clause. Reviewer signals: a `FieldError` argument that names the field, restates the current value, starts with a capital letter, or ends with a period. Each is a sign the author treated `FieldError` like `Error` instead of as a predicate slotted into framework-generated context. \ No newline at end of file +Re-testing a condition you already evaluated, or passing a fully formed sentence like `'The Amount field must be positive.'` to `FieldError`. The result reads as `Amount The Amount field must be positive. in Gen. Journal Line ...` — capital letter mid-sentence, caption and value repeated, and a stray trailing clause. Reviewer signals: a `FieldError` argument that names the field, restates the current value, starts with a capital letter, or ends with a period. Each is a sign the author treated `FieldError` like `Error` instead of as a predicate slotted into framework-generated context. + +See sample: `fielderror-default-message-logic.bad.al`. \ No newline at end of file diff --git a/microsoft/knowledge/error-handling/fielderror-vs-testfield.md b/microsoft/knowledge/error-handling/fielderror-vs-testfield.md index 2208a18..1353c03 100644 --- a/microsoft/knowledge/error-handling/fielderror-vs-testfield.md +++ b/microsoft/knowledge/error-handling/fielderror-vs-testfield.md @@ -14,5 +14,9 @@ application-area: [all] ## Best Practice Use `TestField` when the condition is a simple presence-or-equality check on a single field — mandatory-field gates and prerequisite checks at the top of a procedure read clearly and self-document intent. Use `FieldError` inside an `OnValidate` trigger or a validation procedure where surrounding business logic has already determined the value is invalid and you want a specific, custom message. Rely on the built-in field-and-record context both methods add rather than re-stating the field name in the text. +See sample: `fielderror-vs-testfield.good.al`. + ## Anti Pattern Calling `FieldError` to "test" a field — placing it on a path that is reached unconditionally and expecting it to validate — terminates execution every time because `FieldError` never evaluates a condition. The inverse smell is reaching for `TestField` when the rule needs a tailored message, then bolting a vague generic string onto a check that cannot express the real business reason. A reviewer can spot the first by a `FieldError` that is not guarded by a preceding `if`, and the second by a `TestField` whose intent comment describes a condition more complex than presence or equality. + +See sample: `fielderror-vs-testfield.bad.al`. diff --git a/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md index f61553d..52e3e40 100644 --- a/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md +++ b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md @@ -24,3 +24,7 @@ See sample: `ignored-tryfunction-return-disables-try-semantics.good.al`. Calling a `[TryFunction]` procedure as a standalone statement and assuming the attribute suppresses its errors. The call has ordinary error semantics because its Boolean result is ignored. See sample: `ignored-tryfunction-return-disables-try-semantics.bad.al`. + +## See also + +`microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md` owns transaction rollback expectations after a try method has actually caught an error. diff --git a/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.bad.al b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.bad.al new file mode 100644 index 0000000..0666e0e --- /dev/null +++ b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.bad.al @@ -0,0 +1,18 @@ +pageextension 50445 "Customer Balance Hidden" extends "Customer Card" +{ + layout + { + addlast(General) + { + field(Balance; Rec.Balance) + { + ApplicationArea = All; + ToolTip = 'Specifies the customer balance.'; + Visible = ShowBalance; + } + } + } + + var + ShowBalance: Boolean; +} diff --git a/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.good.al b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.good.al new file mode 100644 index 0000000..d1aea4c --- /dev/null +++ b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.good.al @@ -0,0 +1,29 @@ +pageextension 50444 "Customer Balance Lazy" extends "Customer Card" +{ + layout + { + addlast(General) + { + field("Balance Preview"; BalancePreview) + { + ApplicationArea = All; + Caption = 'Balance Preview'; + ToolTip = 'Specifies the balance when balance details are enabled.'; + Visible = ShowBalance; + } + } + } + + trigger OnAfterGetCurrRecord() + begin + Clear(BalancePreview); + if not ShowBalance then + exit; + Rec.CalcFields(Balance); + BalancePreview := Rec.Balance; + end; + + var + BalancePreview: Decimal; + ShowBalance: Boolean; +} diff --git a/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.md b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.md new file mode 100644 index 0000000..b03ac51 --- /dev/null +++ b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [flowfield, visible, page-control, calculate-only-visible-flowfields, feature-management, hidden-field] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Hidden page FlowFields still calculate unless visible-only calculation is enabled + +## Description + +By default, a FlowField used directly as a page control's source is calculated when the page loads even when `Visible = false` or its visibility expression evaluates to false. The hidden control can therefore issue an aggregate query that no user sees. Business Central 26 introduced the **Calculate only visible FlowFields** feature-management option; only environments with that option enabled skip calculation for controls that are not visible. + +## Best Practice + +On BC 26 and later, enable and verify the visible-only FlowField feature before relying on `Visible` to suppress calculation. When the target environment does not guarantee that option, avoid binding an expensive FlowField directly to a usually-hidden control: calculate it only in the branch that displays it and bind the page control to a variable. Do not flag a hidden FlowField when the v26 feature is known to be enabled or the FlowField is cheap and intentionally preloaded. + +See sample: `hidden-flowfields-still-calculate-before-bc26-opt-in.good.al`. + +## Anti Pattern + +Adding a costly Sum or Lookup FlowField to a page with `Visible = SomeRareMode` and assuming the hidden state prevents its query on all supported versions. The review signal is the direct FlowField source plus conditional or false visibility, not visibility alone. + +See sample: `hidden-flowfields-still-calculate-before-bc26-opt-in.bad.al`. 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 index bb015f6..6070b76 100644 --- a/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md +++ b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md @@ -11,11 +11,11 @@ application-area: [all] ## 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. +`[TryFunction]` lets a caller catch an error, but database changes made before that error are not rolled back. The attribute catches; it does not unwind transaction state. This is the critical distinction from `Codeunit.Run`, which can provide an atomic rollback boundary (see `codeunit-run-as-atomic-sub-operation.md`). On Business Central on-premises, writes inside a try method are blocked by default unless `DisableWriteInsideTryFunctions` is set to `false`; SaaS does not provide that server setting. ## 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. +Reach for `[TryFunction]` when you want to catch a failure without unwinding the transaction — for example, third-party interop or parsing whose error you intend to translate. When writes 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." @@ -23,6 +23,10 @@ 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. +Wrapping database writes in `[TryFunction]` and expecting successful writes before the error to roll back. They remain, the caller receives `false`, and partially applied state can escape. Defensive sprinkling is also unsafe: every catch overwrites the session error buffer and can hide the failure a later helper intended to inspect. See sample: `use-tryfunction-for-error-catching-not-rollback.bad.al`. + +## See also + +`microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md` owns the separate call-site rule that a try method's Boolean result must be consumed. diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md index 5d292fa..f9245b1 100644 --- a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md +++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md @@ -15,7 +15,7 @@ Error method trace telemetry includes the AL error string only when the first `E ## Best Practice -Declare the complete message as a `Label` or `TextConst` and pass it directly to `Error`, followed by substitution values. The client receives the formatted message while telemetry can retain the static message template without using the dynamic values as its message. See `error-direct-substitution-safe-for-telemetry.md`. +Declare the complete message as a `Label` or `TextConst` and pass it directly to `Error`, followed by substitution values. The client receives the formatted message while telemetry retains the static message template without using the dynamic values as its message. Independently review whether each substitution value is appropriate to show to the current user. See sample: `avoid-strsubstno-prebuild-before-error.good.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 deleted file mode 100644 index 1b8c886..0000000 --- a/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index f4cac77..0000000 --- a/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -bc-version: [20..] -domain: privacy -keywords: [error, strsubstno, direct-substitution, telemetry, classification, label] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use a Label or TextConst for the Error telemetry message - -## Description - -For Error method trace telemetry, the platform includes the AL error string only when `Error` receives a `Label` or `TextConst` as its first argument. Substitution values format the client message, but the static label supplies the telemetry message and preserves its classification context. A string literal, local `Text`, `StrSubstNo` result, or concatenation is not equivalent: telemetry substitutes generic guidance instead of that dynamic string. - -## Best Practice - -Define the complete error template as a `Label` with placeholder comments, pass the label directly as the first argument, and pass values separately. Independently review whether those values are appropriate to show to the current user. - -See sample: `error-direct-substitution-safe-for-telemetry.good.al`. - -## Anti Pattern - -Assuming that any direct format string is telemetry-safe, or that a `StrSubstNo`/concatenated first argument is logged verbatim. The required telemetry shape is specifically a directly supplied `Label` or `TextConst`; see `avoid-strsubstno-prebuild-before-error.md`. 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 index 06970e2..ef5ff7c 100644 --- a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al +++ b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al @@ -2,20 +2,20 @@ codeunit 50213 "Privacy Sample Telemetry Bad" { procedure LogCustomerProcessed(var Customer: Record Customer) begin - Session.LogMessage('0000', StrSubstNo('Processed %1', Customer.Name), Verbosity::Normal, + Session.LogMessage('PRIV0001', 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, + Session.LogMessage('PRIV0002', 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, + Session.LogMessage('PRIV0003', 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 index 96a553a..a67e2d9 100644 --- a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al +++ b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al @@ -2,14 +2,14 @@ codeunit 50212 "Privacy Sample Telemetry Good" { procedure LogCustomerProcessed(var Customer: Record Customer) begin - Session.LogMessage('0000', 'Customer record processed', Verbosity::Normal, + Session.LogMessage('PRIV0001', 'Customer record processed', Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', 'Privacy'); end; procedure LogFileError() begin - Session.LogMessage('0001', 'Error processing uploaded file', Verbosity::Error, + Session.LogMessage('PRIV0002', 'Error processing uploaded file', Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::All); end; } diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al index e895d7c..6428b08 100644 --- a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al +++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al @@ -2,6 +2,6 @@ codeunit 50211 "Privacy Sample LogMessage Bad" { procedure LogCompleted() begin - Session.LogMessage('0003', 'Operation completed', Verbosity::Normal); + Session.LogMessage('PRIV0004', '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 index d3353ec..3f78158 100644 --- a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al +++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al @@ -2,7 +2,7 @@ codeunit 50210 "Privacy Sample LogMessage Good" { procedure LogCompleted() begin - Session.LogMessage('0003', 'Operation completed', Verbosity::Normal, + Session.LogMessage('PRIV0004', 'Operation completed', Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher); end; } diff --git a/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.bad.al b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.bad.al new file mode 100644 index 0000000..7d9411b --- /dev/null +++ b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.bad.al @@ -0,0 +1,28 @@ +query 50426 "Query Reuse Bad" +{ + QueryType = Normal; + + elements + { + dataitem(Customer; Customer) + { + column(CustomerNo; "No.") { } + } + } +} + +codeunit 50427 "Query Reuse Bad" +{ + procedure ReadAgain(CustomerNoFilter: Code[20]) + var + CustomerQuery: Query "Query Reuse Bad"; + begin + CustomerQuery.SetRange(CustomerNo, CustomerNoFilter); + CustomerQuery.Open(); + if CustomerQuery.Read() then; + + // Reopening resets to the first row and retains CustomerNo. + CustomerQuery.Open(); + if CustomerQuery.Read() then; + end; +} diff --git a/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.good.al b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.good.al new file mode 100644 index 0000000..4079455 --- /dev/null +++ b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.good.al @@ -0,0 +1,39 @@ +query 50424 "Query Reuse Good" +{ + QueryType = Normal; + + elements + { + dataitem(Customer; Customer) + { + column(CustomerNo; "No.") { } + } + } +} + +codeunit 50425 "Query Reuse Good" +{ + procedure ReadTwoIndependentSets(FirstNo: Code[20]; SecondNo: Code[20]) + var + CustomerQuery: Query "Query Reuse Good"; + begin + CustomerQuery.SetRange(CustomerNo, FirstNo); + ReadAll(CustomerQuery); + + Clear(CustomerQuery); + CustomerQuery.SetRange(CustomerNo, SecondNo); + ReadAll(CustomerQuery); + end; + + local procedure ReadAll(var CustomerQuery: Query "Query Reuse Good") + begin + CustomerQuery.Open(); + while CustomerQuery.Read() do + ProcessCustomer(CustomerQuery.CustomerNo); + CustomerQuery.Close(); + end; + + local procedure ProcessCustomer(CustomerNo: Code[20]) + begin + end; +} diff --git a/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md new file mode 100644 index 0000000..4bc8816 --- /dev/null +++ b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: query +keywords: [query, open, close, clear, cursor, filters, reuse] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Reopening a Query resets its cursor but keeps its filters + +## Description + +Calling `Open()` on an already open query first closes the current dataset and opens it again. The next `Read()` starts at the first row; it does not continue from the previous cursor. Reopening also retains filters previously applied to the query variable. Only `Clear(QueryVariable)` resets those filters, so reuse can unexpectedly reread the first row or carry an old filter into a logically separate operation. + +## Best Practice + +Open once for one read pass. Close after the pass, and call `Clear(QueryVariable)` before reusing the variable for a logically independent query whose filters must start empty. Set the next pass's filters explicitly before reopening. + +See sample: `reopening-query-resets-cursor-but-keeps-filters.good.al`. + +## Anti Pattern + +Calling `Open()` inside or between reads to "advance" or "start fresh", or reusing the same query variable for a new operation while assuming `Open()` cleared old filters. The code compiles but can repeatedly process the first row or silently omit rows behind a retained filter. + +See sample: `reopening-query-resets-cursor-but-keeps-filters.bad.al`. diff --git a/microsoft/knowledge/query/set-query-filters-before-open.bad.al b/microsoft/knowledge/query/set-query-filters-before-open.bad.al new file mode 100644 index 0000000..eed5f9e --- /dev/null +++ b/microsoft/knowledge/query/set-query-filters-before-open.bad.al @@ -0,0 +1,30 @@ +query 50422 "Query Customer Sales Bad" +{ + QueryType = Normal; + + elements + { + dataitem(Customer; Customer) + { + column(CustomerNo; "No.") { } + column(CustomerName; Name) { } + } + } +} + +codeunit 50423 "Query Filter Order Bad" +{ + procedure ReadCustomer(CustomerNoFilter: Code[20]) + var + CustomerSales: Query "Query Customer Sales Bad"; + begin + CustomerSales.Open(); + CustomerSales.SetRange(CustomerNo, CustomerNoFilter); + while CustomerSales.Read() do + ProcessCustomer(CustomerSales.CustomerNo); + end; + + local procedure ProcessCustomer(CustomerNo: Code[20]) + begin + end; +} diff --git a/microsoft/knowledge/query/set-query-filters-before-open.good.al b/microsoft/knowledge/query/set-query-filters-before-open.good.al new file mode 100644 index 0000000..86bd2d4 --- /dev/null +++ b/microsoft/knowledge/query/set-query-filters-before-open.good.al @@ -0,0 +1,31 @@ +query 50420 "Query Customer Sales Good" +{ + QueryType = Normal; + + elements + { + dataitem(Customer; Customer) + { + column(CustomerNo; "No.") { } + column(CustomerName; Name) { } + } + } +} + +codeunit 50421 "Query Filter Order Good" +{ + procedure ReadCustomer(CustomerNoFilter: Code[20]) + var + CustomerSales: Query "Query Customer Sales Good"; + begin + CustomerSales.SetRange(CustomerNo, CustomerNoFilter); + CustomerSales.Open(); + while CustomerSales.Read() do + ProcessCustomer(CustomerSales.CustomerNo); + CustomerSales.Close(); + end; + + local procedure ProcessCustomer(CustomerNo: Code[20]) + begin + end; +} diff --git a/microsoft/knowledge/query/set-query-filters-before-open.md b/microsoft/knowledge/query/set-query-filters-before-open.md new file mode 100644 index 0000000..f999456 --- /dev/null +++ b/microsoft/knowledge/query/set-query-filters-before-open.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: query +keywords: [query, setfilter, setrange, open, read, dataset, filter-order] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Set Query filters before Open + +## Description + +`Query.SetFilter` and `Query.SetRange` automatically close an open query dataset. A call placed after `Open()` therefore does not refine the rows already being read; it ends that dataset. The next `Read()` has no open dataset unless the code explicitly calls `Open()` again, so a plausible filter change can turn a working loop into an empty or failing read sequence without a compiler diagnostic. + +## Best Practice + +Apply every filter before `Open()`, then read the dataset to completion and call `Close()`. When a later branch needs different filters, close or clear the query, set the new filters, and open a new dataset deliberately. + +See sample: `set-query-filters-before-open.good.al`. + +## Anti Pattern + +`Query.Open()` followed by `SetFilter` or `SetRange` and then `Read()` under the assumption that the filter updates the open cursor. Refiltering after `Open()` is valid only when the code intentionally opens a fresh dataset afterward. + +See sample: `set-query-filters-before-open.bad.al`. diff --git a/microsoft/knowledge/security/classify-every-field-with-dataclassification.bad.al b/microsoft/knowledge/security/classify-every-field-with-dataclassification.bad.al deleted file mode 100644 index a9ae935..0000000 --- a/microsoft/knowledge/security/classify-every-field-with-dataclassification.bad.al +++ /dev/null @@ -1,28 +0,0 @@ -table 50100 "Customer Feedback" -{ - fields - { - field(1; "Feedback No."; Code[20]) - { - // No DataClassification declared. Defaults to ToBeClassified. - } - field(2; "Contact Name"; Text[100]) - { - DataClassification = ToBeClassified; - } - field(3; "Email"; Text[80]) - { - // Personal data classified as CustomerContent understates privacy impact. - DataClassification = CustomerContent; - } - field(4; "Feedback Text"; Text[2048]) - { - DataClassification = ToBeClassified; - } - } - - keys - { - key(PK; "Feedback No.") { Clustered = true; } - } -} diff --git a/microsoft/knowledge/security/classify-every-field-with-dataclassification.good.al b/microsoft/knowledge/security/classify-every-field-with-dataclassification.good.al deleted file mode 100644 index baa3079..0000000 --- a/microsoft/knowledge/security/classify-every-field-with-dataclassification.good.al +++ /dev/null @@ -1,36 +0,0 @@ -table 50100 "Customer Feedback" -{ - fields - { - field(1; "Feedback No."; Code[20]) - { - DataClassification = SystemMetadata; - } - field(2; "Contact Name"; Text[100]) - { - DataClassification = EndUserIdentifiableInformation; - } - field(3; "Email"; Text[80]) - { - DataClassification = EndUserIdentifiableInformation; - } - field(4; "Product Code"; Code[20]) - { - DataClassification = CustomerContent; - } - field(5; "Feedback Text"; Text[2048]) - { - // When uncertain between CustomerContent and EUII, prefer the stronger protection. - DataClassification = EndUserIdentifiableInformation; - } - field(6; "Submitted DateTime"; DateTime) - { - DataClassification = SystemMetadata; - } - } - - keys - { - key(PK; "Feedback No.") { Clustered = true; } - } -} diff --git a/microsoft/knowledge/security/classify-every-field-with-dataclassification.md b/microsoft/knowledge/security/classify-every-field-with-dataclassification.md deleted file mode 100644 index 1b854aa..0000000 --- a/microsoft/knowledge/security/classify-every-field-with-dataclassification.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [dataclassification, gdpr, privacy, euii, compliance] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Classify every field with DataClassification - -## Description - -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. 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, 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/security/internal-access-is-not-a-security-boundary.bad.al b/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.bad.al new file mode 100644 index 0000000..f662f8c --- /dev/null +++ b/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.bad.al @@ -0,0 +1,15 @@ +codeunit 50471 "Unprotected Setup Action" +{ + Access = Internal; + + trigger OnRun() + begin + // Internal does not prevent another extension from invoking this OnRun + // through Codeunit.Run. + UpdateSensitiveSetup(); + end; + + local procedure UpdateSensitiveSetup() + begin + end; +} diff --git a/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.good.al b/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.good.al new file mode 100644 index 0000000..0691c31 --- /dev/null +++ b/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.good.al @@ -0,0 +1,39 @@ +table 50468 "Sensitive Setup" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Primary Key"; Code[10]) { } + } +} + +codeunit 50469 "Setup Authorization" +{ + procedure CanManageSetup(): Boolean + var + SensitiveSetup: Record "Sensitive Setup"; + begin + exit(SensitiveSetup.WritePermission()); + end; +} + +codeunit 50470 "Protected Setup Action" +{ + Access = Internal; + + trigger OnRun() + begin + if not SetupAuthorization.CanManageSetup() then + Error(NotAuthorizedErr); + UpdateSensitiveSetup(); + end; + + local procedure UpdateSensitiveSetup() + begin + end; + + var + SetupAuthorization: Codeunit "Setup Authorization"; + NotAuthorizedErr: Label 'You are not authorized to manage this setup.'; +} diff --git a/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.md b/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.md new file mode 100644 index 0000000..bdd8462 --- /dev/null +++ b/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: security +keywords: [access, internal, internalsvisibleto, recordref, codeunit-run, security-boundary, authorization] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Access Internal is API hygiene, not an authorization boundary + +## Description + +`Access = Internal` controls compile-time symbol visibility. It does not prevent runtime access through mechanisms such as `RecordRef`, `TransferFields`, or `Codeunit.Run`, and `internalsVisibleTo` deliberately grants compile-time access to named companion apps. Microsoft explicitly documents that access modifiers cannot be used as a security boundary. + +## Best Practice + +Use `internal` to keep implementation details out of the supported API, but enforce sensitive operations with permissions, entitlements, and explicit authorization checks appropriate to the operation. Treat `internalsVisibleTo` as a same-publisher development/testability relationship, not as a trust grant for secrets or elevated data access. + +See sample: `internal-access-is-not-a-security-boundary.good.al`. + +## Anti Pattern + +Placing privileged work in an internal codeunit and claiming that other extensions cannot invoke it, or exposing an app to a different publisher through `internalsVisibleTo` because `internal` is assumed to protect the underlying operation. The access modifier narrows supported callers; it does not authenticate runtime callers. + +See sample: `internal-access-is-not-a-security-boundary.bad.al`. diff --git a/microsoft/knowledge/security/secrets-isolated-storage.md b/microsoft/knowledge/security/secrets-isolated-storage.md index 91afe69..1434c1a 100644 --- a/microsoft/knowledge/security/secrets-isolated-storage.md +++ b/microsoft/knowledge/security/secrets-isolated-storage.md @@ -17,6 +17,10 @@ API keys, OAuth tokens, client secrets, and connection strings must not be store Persist every credential in `IsolatedStorage`, write it at the point of capture, and read it only when needed. Prefer `SetEncrypted` when the value fits its documented length limit. On BC24 and later, carry the value through the `SecretText` overloads; on earlier releases, keep any required `Text` handling inside a `[NonDebuggable]` boundary. Choose the `DataScope` that matches the credential's lifetime. See `isolatedstorage-datascope-module-vs-company`, `isolatedstorage-setencrypted-for-sensitive-values`, and `secrettext-for-credentials` for those separate concerns. +See sample: `secrets-isolated-storage.good.al`. + ## Anti Pattern A "Setup" or "Connection" table carrying a `Text` field named `API Key`, `Password`, or `Client Secret`. The value is now readable by any object with table permission, ships in RapidStart packages and Excel exports, and appears in record snapshots — a credential disclosure that no amount of encryption-in-transit elsewhere makes up for. Reviewer signal: a secret-shaped field declared on a table instead of an `IsolatedStorage` call. + +See sample: `secrets-isolated-storage.bad.al`. diff --git a/microsoft/knowledge/security/secrettext-for-credentials.md b/microsoft/knowledge/security/secrettext-for-credentials.md index 26a2511..0eb6cb8 100644 --- a/microsoft/knowledge/security/secrettext-for-credentials.md +++ b/microsoft/knowledge/security/secrettext-for-credentials.md @@ -19,4 +19,4 @@ Declare credential-carrying parameters and variables as `SecretText` from the ca ## 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`. +Holding a credential in a `Text` variable (`BearerToken: Text`) makes it 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`. When the same value is visibly sent through an HTTP URI, header, or body, `secrettext-with-httpclient.md` is the more specific primary rule. See sample: `secrettext-for-credentials.bad.al`. diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.md b/microsoft/knowledge/security/secrettext-with-httpclient.md index dc67e3e..689a7b6 100644 --- a/microsoft/knowledge/security/secrettext-with-httpclient.md +++ b/microsoft/knowledge/security/secrettext-with-httpclient.md @@ -19,4 +19,4 @@ Compose a secret URI with `SecretStrSubstNo`, call `Request.SetSecretRequestUri( ## Anti Pattern -Holding a credential in `Text`, interpolating it with `StrSubstNo` or concatenation, and passing that plain text to `HttpClient.Get` or `HttpHeaders.Add`. The secret-aware request and header APIs remove the need to materialize the value as `Text`. See sample: `secrettext-with-httpclient.bad.al`. +Holding a credential in `Text`, interpolating it with `StrSubstNo` or concatenation, and passing that plain text to `HttpClient.Get` or `HttpHeaders.Add`. The secret-aware request and header APIs remove the need to materialize the value as `Text`. This HTTP-sink rule supersedes the generic `secrettext-for-credentials.md` rule at the same location. See sample: `secrettext-with-httpclient.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 index bd12f36..fd458a4 100644 --- a/microsoft/knowledge/style/caption-required-on-page-fields.bad.al +++ b/microsoft/knowledge/style/caption-required-on-page-fields.bad.al @@ -1,13 +1,24 @@ -table 50253 "Sample Caption Bad" +page 50253 "Sample Caption Bad" { - fields + PageType = Card; + SourceTable = Customer; + + layout { - field(1; "Customer No."; Code[20]) + area(Content) { - } - field(2; "Is Active"; Boolean) - { - Caption = ''; + group(General) + { + field("Customer No."; Rec."No.") + { + ApplicationArea = All; + } + field("Customer Name"; Rec.Name) + { + ApplicationArea = All; + 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 index 7de715b..0493b6e 100644 --- a/microsoft/knowledge/style/caption-required-on-page-fields.good.al +++ b/microsoft/knowledge/style/caption-required-on-page-fields.good.al @@ -1,17 +1,28 @@ -table 50252 "Sample Caption Good" +page 50252 "Sample Caption Good" { - fields + PageType = Card; + SourceTable = Customer; + + layout { - field(1; "Customer No."; Code[20]) + area(Content) { - Caption = 'Customer No.'; - } - field(2; "Enabled"; Boolean) - { - } - field(3; Amount; Decimal) - { - CaptionClass = '3,5,' + 'USD'; + group(General) + { + Caption = 'General'; + field("Customer No."; Rec."No.") + { + ApplicationArea = All; + Caption = 'Customer No.'; + ToolTip = 'Specifies the customer number.'; + } + field("Customer Name"; Rec.Name) + { + ApplicationArea = All; + Caption = 'Customer Name'; + ToolTip = 'Specifies the customer name.'; + } + } } } } diff --git a/microsoft/knowledge/style/temporary-variable-temp-prefix.md b/microsoft/knowledge/style/temporary-variable-temp-prefix.md index 2211b4c..16e85c2 100644 --- a/microsoft/knowledge/style/temporary-variable-temp-prefix.md +++ b/microsoft/knowledge/style/temporary-variable-temp-prefix.md @@ -15,12 +15,12 @@ A `Record` variable declared with the `temporary` modifier behaves nothing like ## 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. +Every local or global variable of type `Record X temporary` must start with `Temp`. Ordinary procedure parameters follow the same convention. Event publisher parameters are owned by the events-domain rule `prefix-temporary-record-event-parameters-with-temp.md`; the style leaf must not emit a second finding for the same event parameter. 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. +`WIPBuffer: Record "Job WIP Buffer" temporary;` as a local, global, or ordinary procedure parameter reads at the call site as if it were a database operation. Exclude event publisher parameters here so the events leaf remains their single owner. See sample: `temporary-variable-temp-prefix.bad.al`. diff --git a/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.bad.al b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.bad.al new file mode 100644 index 0000000..bd87b07 --- /dev/null +++ b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.bad.al @@ -0,0 +1,26 @@ +codeunit 50401 "Telemetry Scope Bad" +{ + procedure LogIntegrationFailure() + begin + // Tenant operators cannot see an actionable integration failure. + Session.LogMessage( + 'TLM0004', + 'Document exchange failed', + Verbosity::Error, + DataClassification::SystemMetadata, + TelemetryScope::ExtensionPublisher, + 'Operation', 'DocumentExchange'); + end; + + procedure LogCacheMiss() + begin + // Environment telemetry receives publisher-only implementation noise. + Session.LogMessage( + 'TLM0005', + 'Internal cache entry missed', + Verbosity::Verbose, + DataClassification::SystemMetadata, + TelemetryScope::All, + 'Cache', 'ExchangeMetadata'); + end; +} diff --git a/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.good.al b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.good.al new file mode 100644 index 0000000..3233042 --- /dev/null +++ b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.good.al @@ -0,0 +1,24 @@ +codeunit 50400 "Telemetry Scope Good" +{ + procedure LogIntegrationFailure() + begin + Session.LogMessage( + 'TLM0002', + 'Document exchange failed', + Verbosity::Error, + DataClassification::SystemMetadata, + TelemetryScope::All, + 'Operation', 'DocumentExchange'); + end; + + procedure LogCacheMiss() + begin + Session.LogMessage( + 'TLM0003', + 'Internal cache entry missed', + Verbosity::Verbose, + DataClassification::SystemMetadata, + TelemetryScope::ExtensionPublisher, + 'Cache', 'ExchangeMetadata'); + end; +} diff --git a/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md new file mode 100644 index 0000000..940c9c8 --- /dev/null +++ b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md @@ -0,0 +1,26 @@ +--- +bc-version: [17..] +domain: telemetry +keywords: [telemetryscope, extensionpublisher, all, audience, logmessage, application-insights] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Choose TelemetryScope by who must receive the signal + +## Description + +`TelemetryScope::ExtensionPublisher` sends a custom trace only to the Application Insights resource configured by the extension publisher. `TelemetryScope::All` also sends it to the environment's telemetry, where the customer or partner operating the tenant can query it. The compiler accepts either value, so a plausible-looking scope can silently hide an actionable signal from tenant operators or expose publisher-only implementation noise to them. + +## Best Practice + +Use `ExtensionPublisher` for internal diagnostics that only the app publisher can interpret, such as cache behavior or private algorithm state. Use `All` for signals the tenant operator can act on, such as an integration failure, quota warning, or setup problem. Decide the audience independently from `DataClassification`; privacy guidance still governs whether the payload may be emitted at all. + +See sample: `choose-telemetry-scope-by-audience.good.al`. + +## Anti Pattern + +Defaulting every call to `All`, including low-level implementation diagnostics, or defaulting every call to `ExtensionPublisher` and thereby hiding customer-actionable failures from environment telemetry. Review only when the message and surrounding branch make the intended audience clear; an ambiguous diagnostic is not enough to infer the wrong scope. + +See sample: `choose-telemetry-scope-by-audience.bad.al`. diff --git a/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.bad.al b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.bad.al new file mode 100644 index 0000000..39f2940 --- /dev/null +++ b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.bad.al @@ -0,0 +1,11 @@ +codeunit 50405 "Feature Uptake Bad" +{ + procedure FeatureOpened() + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + // The first uptake state skips Discovered and is not emitted. + FeatureTelemetry.LogUptake( + 'TLM0011', 'Document exchange', Enum::"Feature Uptake Status"::Used); + end; +} diff --git a/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.good.al b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.good.al new file mode 100644 index 0000000..323bdd6 --- /dev/null +++ b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.good.al @@ -0,0 +1,26 @@ +codeunit 50404 "Feature Uptake Good" +{ + procedure FeatureDiscovered() + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + FeatureTelemetry.LogUptake( + 'TLM0008', 'Document exchange', Enum::"Feature Uptake Status"::Discovered); + end; + + procedure FeatureSetUp() + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + FeatureTelemetry.LogUptake( + 'TLM0009', 'Document exchange', Enum::"Feature Uptake Status"::"Set up"); + end; + + procedure FeatureUsed() + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + FeatureTelemetry.LogUptake( + 'TLM0010', 'Document exchange', Enum::"Feature Uptake Status"::Used); + end; +} diff --git a/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md new file mode 100644 index 0000000..eb05742 --- /dev/null +++ b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md @@ -0,0 +1,26 @@ +--- +bc-version: [18..] +domain: telemetry +keywords: [featuretelemetry, loguptake, discovered, set-up, used, uptake-status, lifecycle] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Emit FeatureTelemetry uptake states in lifecycle order + +## Description + +`FeatureTelemetry.LogUptake` accepts `Discovered`, `Set up`, `Used`, and `Undiscovered`, but the platform records the forward transition only as `Discovered` to `Set up` to `Used`. If the first call for a feature is `Set up` or `Used`, no uptake telemetry is emitted. `Undiscovered` is the explicit reset from any state. + +## Best Practice + +Log `Discovered` when the user encounters the feature, `Set up` after its setup is completed, and `Used` when the user attempts it. Keep the same feature name throughout the funnel. Review ordering only when the changed repository context shows the feature's lifecycle; a single isolated `Used` call cannot prove that earlier states are absent elsewhere. + +See sample: `feature-uptake-transitions-in-order.good.al`. + +## Anti Pattern + +Introducing a feature whose only uptake call jumps directly to `Set up` or `Used`, or using different feature-name literals for successive states. The calls compile and run, but the funnel silently omits the invalid transition. + +See sample: `feature-uptake-transitions-in-order.bad.al`. diff --git a/microsoft/knowledge/telemetry/feature-usage-only-after-success.bad.al b/microsoft/knowledge/telemetry/feature-usage-only-after-success.bad.al new file mode 100644 index 0000000..9c3e2a2 --- /dev/null +++ b/microsoft/knowledge/telemetry/feature-usage-only-after-success.bad.al @@ -0,0 +1,23 @@ +codeunit 50407 "Feature Usage Bad" +{ + procedure ExchangeDocument(ShouldFail: Boolean) + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + FeatureTelemetry.LogUsage( + 'TLM0014', 'Document exchange', 'Document exchanged'); + + if not TryExchangeDocument(ShouldFail) then + exit; + end; + + [TryFunction] + local procedure TryExchangeDocument(ShouldFail: Boolean) + begin + if ShouldFail then + Error(ExchangeFailedErr); + end; + + var + ExchangeFailedErr: Label 'Exchange failed.'; +} diff --git a/microsoft/knowledge/telemetry/feature-usage-only-after-success.good.al b/microsoft/knowledge/telemetry/feature-usage-only-after-success.good.al new file mode 100644 index 0000000..8ce7616 --- /dev/null +++ b/microsoft/knowledge/telemetry/feature-usage-only-after-success.good.al @@ -0,0 +1,27 @@ +codeunit 50406 "Feature Usage Good" +{ + procedure ExchangeDocument(ShouldFail: Boolean) + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + if not TryExchangeDocument(ShouldFail) then begin + FeatureTelemetry.LogError( + 'TLM0012', 'Document exchange', 'Exchanging document', + GetLastErrorText(true), GetLastErrorCallStack()); + exit; + end; + + FeatureTelemetry.LogUsage( + 'TLM0013', 'Document exchange', 'Document exchanged'); + end; + + [TryFunction] + local procedure TryExchangeDocument(ShouldFail: Boolean) + begin + if ShouldFail then + Error(ExchangeFailedErr); + end; + + var + ExchangeFailedErr: Label 'Exchange failed.'; +} diff --git a/microsoft/knowledge/telemetry/feature-usage-only-after-success.md b/microsoft/knowledge/telemetry/feature-usage-only-after-success.md new file mode 100644 index 0000000..a1f118b --- /dev/null +++ b/microsoft/knowledge/telemetry/feature-usage-only-after-success.md @@ -0,0 +1,26 @@ +--- +bc-version: [18..] +domain: telemetry +keywords: [featuretelemetry, logusage, logerror, success, tryfunction, feature-usage] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Call FeatureTelemetry.LogUsage only after successful use + +## Description + +`FeatureTelemetry.LogUsage` means that a user successfully used the feature. An attempt belongs in the uptake funnel, while a failed operation belongs in `LogError`. Logging usage before checking the result inflates adoption metrics with failed attempts and makes usage telemetry disagree with the actual business outcome. + +## Best Practice + +Call `LogUsage` only after the operation has completed successfully. On a failure path, call `LogError` with the captured error text and call stack when the failure must be emitted explicitly. Use a past-tense event name for usage and a present-tense scenario name for errors. + +See sample: `feature-usage-only-after-success.good.al`. + +## Anti Pattern + +Calling `LogUsage` before a Boolean result, `TryFunction`, `Codeunit.Run`, or HTTP status has been checked, or calling it in both success and failure branches. Do not flag an attempt recorded with `LogUptake(...Used)`; unlike `LogUsage`, that state intentionally records an attempt. + +See sample: `feature-usage-only-after-success.bad.al`. diff --git a/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.bad.al b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.bad.al new file mode 100644 index 0000000..4abc1c9 --- /dev/null +++ b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.bad.al @@ -0,0 +1,14 @@ +codeunit 50412 "Telemetry Dimension Bad" +{ + procedure LogBatchResult(RecordCount: Integer) + var + CustomDimensions: Dictionary of [Text, Text]; + begin + CustomDimensions.Add('record count', Format(RecordCount)); + CustomDimensions.Add('result_code', 'Success'); + Session.LogMessage( + 'TLM0015', 'Order processing completed', Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, + CustomDimensions); + end; +} diff --git a/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.good.al b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.good.al new file mode 100644 index 0000000..e8f99af --- /dev/null +++ b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.good.al @@ -0,0 +1,14 @@ +codeunit 50411 "Telemetry Dimension Good" +{ + procedure LogBatchResult(RecordCount: Integer) + var + CustomDimensions: Dictionary of [Text, Text]; + begin + CustomDimensions.Add('RecordCount', Format(RecordCount)); + CustomDimensions.Add('Result', 'Success'); + Session.LogMessage( + 'TLM0015', 'Order processing completed', Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, + CustomDimensions); + end; +} diff --git a/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md new file mode 100644 index 0000000..4c2c41a --- /dev/null +++ b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md @@ -0,0 +1,26 @@ +--- +bc-version: [17..] +domain: telemetry +keywords: [customdimensions, dimension-key, schema, pascalcase, kql, breaking-change] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Treat custom dimension keys as a stable telemetry schema + +## Description + +Business Central prefixes AL custom-dimension keys with `al` in Application Insights, so an AL key named `Result` becomes `alResult`. Microsoft guidance treats telemetry definitions as an API: changing or removing a custom dimension can break dashboards and alerts. PascalCase keys without spaces also compose cleanly in KQL; spaces force awkward bracket access and make queries harder to maintain. + +## Best Practice + +Choose stable PascalCase keys such as `Operation`, `Result`, and `RecordCount`. Keep the key set and meaning stable for a shipped event ID; add a new event ID or coordinate a schema migration when the meaning must change. Privacy guidance separately governs whether a dimension value may contain customer data. + +See sample: `keep-custom-dimension-schema-stable.good.al`. + +## Anti Pattern + +Keys such as `'order no'` or `'result_code'`, or renaming/removing a key while retaining the same shipped event ID. A naming-only issue is advisory; changing an existing event's schema is the material compatibility defect. New keys on a new event ID are not a breaking change. + +See sample: `keep-custom-dimension-schema-stable.bad.al`. diff --git a/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.bad.al b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.bad.al new file mode 100644 index 0000000..d31fa23 --- /dev/null +++ b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.bad.al @@ -0,0 +1,24 @@ +codeunit 50403 "Telemetry Verbosity Bad" +{ + procedure RunExchange() + begin + if TryExchange() then + exit; + + Session.LogMessage( + 'TLM0007', + 'Document exchange failed', + Verbosity::Normal, + DataClassification::SystemMetadata, + TelemetryScope::All); + end; + + [TryFunction] + local procedure TryExchange() + begin + Error(ExchangeFailedErr); + end; + + var + ExchangeFailedErr: Label 'Exchange failed.'; +} diff --git a/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.good.al b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.good.al new file mode 100644 index 0000000..c730b1b --- /dev/null +++ b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.good.al @@ -0,0 +1,24 @@ +codeunit 50402 "Telemetry Verbosity Good" +{ + procedure RunExchange() + begin + if TryExchange() then + exit; + + Session.LogMessage( + 'TLM0006', + 'Document exchange failed', + Verbosity::Error, + DataClassification::SystemMetadata, + TelemetryScope::All); + end; + + [TryFunction] + local procedure TryExchange() + begin + Error(ExchangeFailedErr); + end; + + var + ExchangeFailedErr: Label 'Exchange failed.'; +} diff --git a/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md new file mode 100644 index 0000000..4ff780a --- /dev/null +++ b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md @@ -0,0 +1,26 @@ +--- +bc-version: [17..] +domain: telemetry +keywords: [verbosity, severitylevel, critical, error, warning, normal, verbose, logmessage] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Match telemetry Verbosity to the signal's actual severity + +## Description + +`Verbosity` becomes the Application Insights `severityLevel` and participates in on-premises diagnostic trace filtering. `Critical` represents abnormal termination, `Error` a severe error, `Warning` a warning, `Normal` a non-error event, and `Verbose` detailed tracing. Logging a caught failure as `Normal` is not cosmetic: severity-based alerts miss it, and an on-premises service configured to emit only warnings and above can drop it completely. + +## Best Practice + +Use `Error` for failed operations that need investigation and `Critical` only for abnormal termination or equivalent loss of service. Use `Warning` for degraded but completed behavior, `Normal` for successful business events, and `Verbose` for detailed diagnostics. Judge the outcome, not the procedure name: an expected optional lookup miss can legitimately remain `Normal` or `Verbose`. + +See sample: `match-verbosity-to-signal-severity.good.al`. + +## Anti Pattern + +A `Session.LogMessage` in a failed `TryFunction`, failed `Codeunit.Run`, unsuccessful HTTP response, or other explicit failure branch that uses `Verbosity::Normal` or `Verbose` without evidence that the failure is expected and benign. + +See sample: `match-verbosity-to-signal-severity.bad.al`. diff --git a/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.bad.al b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.bad.al new file mode 100644 index 0000000..e186247 --- /dev/null +++ b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.bad.al @@ -0,0 +1,37 @@ +codeunit 50409 "First Telemetry Logger" implements "Telemetry Logger" +{ + Access = Internal; + + procedure LogMessage(EventId: Text; Message: Text; Verbosity: Verbosity; DataClassification: DataClassification; TelemetryScope: TelemetryScope; CustomDimensions: Dictionary of [Text, Text]) + begin + Session.LogMessage( + EventId, Message, Verbosity, DataClassification, TelemetryScope, CustomDimensions); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Telemetry Loggers", 'OnRegisterTelemetryLogger', '', true, true)] + local procedure RegisterFirst(var Sender: Codeunit "Telemetry Loggers") + var + Logger: Codeunit "First Telemetry Logger"; + begin + Sender.Register(Logger); + end; +} + +codeunit 50410 "Second Telemetry Logger" implements "Telemetry Logger" +{ + Access = Internal; + + procedure LogMessage(EventId: Text; Message: Text; Verbosity: Verbosity; DataClassification: DataClassification; TelemetryScope: TelemetryScope; CustomDimensions: Dictionary of [Text, Text]) + begin + Session.LogMessage( + EventId, Message, Verbosity, DataClassification, TelemetryScope, CustomDimensions); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Telemetry Loggers", 'OnRegisterTelemetryLogger', '', true, true)] + local procedure RegisterSecond(var Sender: Codeunit "Telemetry Loggers") + var + Logger: Codeunit "Second Telemetry Logger"; + begin + Sender.Register(Logger); + end; +} diff --git a/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.good.al b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.good.al new file mode 100644 index 0000000..2cbe885 --- /dev/null +++ b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.good.al @@ -0,0 +1,18 @@ +codeunit 50408 "Sample Telemetry Logger" implements "Telemetry Logger" +{ + Access = Internal; + + procedure LogMessage(EventId: Text; Message: Text; Verbosity: Verbosity; DataClassification: DataClassification; TelemetryScope: TelemetryScope; CustomDimensions: Dictionary of [Text, Text]) + begin + Session.LogMessage( + EventId, Message, Verbosity, DataClassification, TelemetryScope, CustomDimensions); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Telemetry Loggers", 'OnRegisterTelemetryLogger', '', true, true)] + local procedure OnRegisterTelemetryLogger(var Sender: Codeunit "Telemetry Loggers") + var + SampleTelemetryLogger: Codeunit "Sample Telemetry Logger"; + begin + Sender.Register(SampleTelemetryLogger); + end; +} diff --git a/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md new file mode 100644 index 0000000..d88c020 --- /dev/null +++ b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md @@ -0,0 +1,26 @@ +--- +bc-version: [18..] +domain: telemetry +keywords: [telemetry-logger, interface, register, publisher, featuretelemetry, onregistertelemetrylogger] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Register exactly one Telemetry Logger implementation per publisher + +## Description + +The `Telemetry` and `Feature Telemetry` codeunits reach an extension publisher's telemetry through an implementation of the `"Telemetry Logger"` interface registered with `"Telemetry Loggers".OnRegisterTelemetryLogger`. The platform requires exactly one registration per app publisher. No registration prevents the module from working as expected; multiple registrations make the destination ambiguous and produce platform error telemetry. + +## Best Practice + +Place one internal logger implementation in one app for the publisher, forward its `LogMessage` method to `Session.LogMessage`, and register it from one event subscriber. Companion apps with the same publisher reuse that registration instead of each adding another. Evaluate absence only with repository or app-family context; a single-file diff cannot prove that no logger exists elsewhere. + +See sample: `register-one-telemetry-logger-per-publisher.good.al`. + +## Anti Pattern + +Adding `FeatureTelemetry` calls to a complete app with no logger registration, or registering two logger implementations for apps that share the same publisher. The calls compile, but the telemetry module reports the missing or duplicate registration instead of behaving as intended. + +See sample: `register-one-telemetry-logger-per-publisher.bad.al`. diff --git a/microsoft/knowledge/style/telemetry-event-id-stable-unique.bad.al b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.bad.al similarity index 89% rename from microsoft/knowledge/style/telemetry-event-id-stable-unique.bad.al rename to microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.bad.al index be60f4b..c9615be 100644 --- a/microsoft/knowledge/style/telemetry-event-id-stable-unique.bad.al +++ b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.bad.al @@ -1,4 +1,4 @@ -codeunit 50260 "Sample Telemetry Id Bad" +codeunit 50260 "Telemetry Event Id Bad" { procedure LogCustomerProcessed(var Customer: Record Customer) begin diff --git a/microsoft/knowledge/style/telemetry-event-id-stable-unique.good.al b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.good.al similarity index 88% rename from microsoft/knowledge/style/telemetry-event-id-stable-unique.good.al rename to microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.good.al index 4491e31..dcdf6ae 100644 --- a/microsoft/knowledge/style/telemetry-event-id-stable-unique.good.al +++ b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.good.al @@ -1,4 +1,4 @@ -codeunit 50261 "Sample Telemetry Id Good" +codeunit 50261 "Telemetry Event Id Good" { procedure LogCustomerProcessed(var Customer: Record Customer) begin diff --git a/microsoft/knowledge/style/telemetry-event-id-stable-unique.md b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.md similarity index 98% rename from microsoft/knowledge/style/telemetry-event-id-stable-unique.md rename to microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.md index da00af3..ca6d2d6 100644 --- a/microsoft/knowledge/style/telemetry-event-id-stable-unique.md +++ b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.md @@ -1,6 +1,6 @@ --- -bc-version: [all] -domain: style +bc-version: [17..] +domain: telemetry keywords: [telemetry, logmessage, event-id, sessionlogmessage, observability] technologies: [al] countries: [w1] diff --git a/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.bad.al b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.bad.al new file mode 100644 index 0000000..46cfed7 --- /dev/null +++ b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.bad.al @@ -0,0 +1,26 @@ +codeunit 50483 "Protected Setup Action Bad" +{ + trigger OnRun() + var + Customer: Record Customer; + begin + Customer.Init(); + Customer."No." := 'SUPER-INSERT'; + Customer.Insert(); + end; +} + +codeunit 50484 "Permission Test Bad" +{ + Subtype = Test; + TestPermissions = Disabled; + + [Test] + procedure LimitedUserCannotRunSetup() + var + SetupAction: Codeunit "Protected Setup Action Bad"; + begin + // Disabled runs as SUPER; no limited-user boundary is exercised. + asserterror SetupAction.Run(); + end; +} diff --git a/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.good.al b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.good.al new file mode 100644 index 0000000..315ce6a --- /dev/null +++ b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.good.al @@ -0,0 +1,43 @@ +permissionset 50480 "LIMITED USER" +{ + Assignable = false; + Permissions = + tabledata Customer = R, + codeunit "Protected Setup Action Test" = X; +} + +codeunit 50481 "Protected Setup Action Test" +{ + trigger OnRun() + var + Customer: Record Customer; + begin + Customer.Init(); + Customer."No." := 'NO-INSERT'; + Customer.Insert(); + end; +} + +codeunit 50482 "Permission Test Good" +{ + Subtype = Test; + TestPermissions = Restrictive; + + [Test] + procedure LimitedUserCannotRunSetup() + var + PermissionsMock: Codeunit "Permissions Mock"; + SetupAction: Codeunit "Protected Setup Action Test"; + begin + PermissionsMock.Start(); + PermissionsMock.SetExactPermissionSet('LIMITED USER'); + + asserterror SetupAction.Run(); + Assert.ExpectedError('permission'); + + PermissionsMock.Stop(); + end; + + var + Assert: Codeunit "Library Assert"; +} diff --git a/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md new file mode 100644 index 0000000..8e3e126 --- /dev/null +++ b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: testing +keywords: [testpermissions, restrictive, disabled, permissions-mock, lower-permissions, super, permission-test] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Permission tests must actually lower the execution context + +## Description + +`TestPermissions` describes how a test runner should establish the permission context; the enum value does not itself assign the business permission set being tested. `Restrictive` is the default and starts from D365 Full Access, requiring the test to lower permissions. `Disabled` leaves the test running as `SUPER`. A test that expects access to be denied while still running with either broad context can pass or fail for the wrong reason and never exercise the intended boundary. + +## Best Practice + +Use `TestPermissions::Restrictive` for a permission-sensitive test and lower the current test user with the test framework's `"Permissions Mock"` or `"Library - Lower Permissions"` before invoking the protected operation. Assign the exact permission set the scenario claims to test and restore or stop the mock afterward. Use `Disabled` only for suites that do not assert permission behavior. + +See sample: `permission-tests-must-lower-the-execution-context.good.al`. + +## Anti Pattern + +Setting `TestPermissions = Disabled` or leaving the effective D365 Full Access context in place while asserting that a limited user is denied, or adding a `[TestPermissions(...)]` attribute without any runner/test-library code that applies the intended permission set. + +See sample: `permission-tests-must-lower-the-execution-context.bad.al`. diff --git a/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.bad.al b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.bad.al new file mode 100644 index 0000000..3d4bbc3 --- /dev/null +++ b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.bad.al @@ -0,0 +1,22 @@ +codeunit 50452 "Isolated Test Runner Bad" +{ + Subtype = TestRunner; + TestIsolation = Disabled; +} + +codeunit 50453 "Committed Write Test Bad" +{ + Subtype = Test; + + [Test] + [TransactionModel(TransactionModel::AutoCommit)] + procedure TestCommittedWrite() + var + Customer: Record Customer; + begin + Customer.Init(); + Customer."No." := 'PERSISTS'; + Customer.Insert(); + Commit(); + end; +} diff --git a/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.good.al b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.good.al new file mode 100644 index 0000000..6bf4c8d --- /dev/null +++ b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.good.al @@ -0,0 +1,22 @@ +codeunit 50450 "Isolated Test Runner Good" +{ + Subtype = TestRunner; + TestIsolation = Codeunit; +} + +codeunit 50451 "Committed Write Test Good" +{ + Subtype = Test; + + [Test] + [TransactionModel(TransactionModel::AutoCommit)] + procedure TestCommittedWrite() + var + Customer: Record Customer; + begin + Customer.Init(); + Customer."No." := 'ISOLATED'; + Customer.Insert(); + Commit(); + end; +} diff --git a/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md new file mode 100644 index 0000000..03d8854 --- /dev/null +++ b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: testing +keywords: [testisolation, testrunner, autocommit, commit, rollback, test-order, database-state] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Configure TestIsolation on the test runner + +## Description + +`TestIsolation` is a property of a `Subtype = TestRunner` codeunit, not of the test codeunit being executed. Its default is `Disabled`. `Codeunit` rolls back database changes after each test codeunit and `Function` after each test method, including changes that the code under test explicitly committed. Without runner isolation, an `AutoCommit` test can leave data behind and make later tests order-dependent. + +## Best Practice + +Run independent suites with `TestIsolation = Codeunit` or `Function`, choosing the narrowest boundary the runner supports. Pair this with the appropriate method-level `TransactionModel`: `AutoCommit` permits code under test to commit, while runner isolation still restores the database afterward. Keep isolation disabled only for an intentionally shared-state suite whose ordering and cleanup are explicit. + +See sample: `testisolation-belongs-on-the-test-runner.good.al`. + +## Anti Pattern + +An `AutoCommit` test exercises committed writes under a test runner that omits `TestIsolation` or sets it to `Disabled`, then assumes the database is restored automatically. This article owns runner-level rollback; `transactionmodel-attribute-governs-test-transactions.md` separately owns the method attribute. + +See sample: `testisolation-belongs-on-the-test-runner.bad.al`. diff --git a/microsoft/knowledge/ui/page-background-tasks.md b/microsoft/knowledge/ui/page-background-tasks.md index 8a1d3fa..c4cccb8 100644 --- a/microsoft/knowledge/ui/page-background-tasks.md +++ b/microsoft/knowledge/ui/page-background-tasks.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [15..] domain: ui keywords: [enqueuebackgroundtask, async-calculation, child-session, factbox, cue-tile, onaftergetcurrrecord, responsive-page, read-only] technologies: [al] 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 index 7a83df2..09a14f5 100644 --- 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 @@ -13,7 +13,7 @@ codeunit 50206 "Upgrade Graceful" begin if not Customer.Get(CustomerNo) then begin Session.LogMessage( - '0000ABC', + 'UPG0001', 'Customer not found during upgrade', Verbosity::Warning, DataClassification::SystemMetadata, diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md index cf09619..43a2e20 100644 --- a/microsoft/skills/review/al-appsource-review.md +++ b/microsoft/skills/review/al-appsource-review.md @@ -37,15 +37,19 @@ 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 files and AL object types — especially `app.json`, `AppSourceCop.json`, new objects, and table/page/report extensions that add fields, keys, controls, or actions to base objects. +- The changed files and AL object types — especially `app.json`, `AppSourceCop.json`, namespace declarations, permission-set objects, new objects, and table/page/report extensions that add fields, keys, controls, or actions to base objects. - The changed object and member names, weighted toward prefix/suffix consistency with `mandatoryAffixes` or `mandatoryPrefix`, plus AppSource-facing help metadata. -- Tokens extracted from the diff that relate to AppSource (`AppSourceCop`, `mandatoryAffixes`, `mandatoryPrefix`, `AS0011`, `prefix`, `suffix`, `tableextension`, `pageextension`, `reportextension`, `field`, `key`, `control`, `action`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`). +- Tokens extracted from the diff that relate to AppSource (`AppSourceCop`, `mandatoryAffixes`, `mandatoryPrefix`, `AS0011`, `prefix`, `suffix`, `namespace`, `using`, `permissionset`, `Assignable`, `Permissions`, `SUPER`, `tableextension`, `pageextension`, `reportextension`, `field`, `key`, `control`, `action`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no AppSource-related source or metadata changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. The following targeted checks cover every current `appsource` article across the Microsoft and community layers. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action. -- A new or renamed object lacks the reserved prefix/suffix, or a tableextension/pageextension/reportextension adds an unaffixed field, key, control, or action to a base object despite `mandatoryAffixes`/`mandatoryPrefix` and AS0011 — `object-affixes-prevent-collisions`. +- Select exactly one naming-collision owner. When no namespace declaration is present, a new/renamed object lacks the reserved prefix/suffix, or an extension object adds an unaffixed member to a base object — `object-affixes-prevent-collisions`. +- For BC23 or later, use `two-level-namespace-replaces-object-affix-not-extension-member-affix` instead when the changed source actually declares or changes a namespace and relies on it as the owned-object affix alternative, but has fewer than two levels or incorrectly applies that exception to members on another publisher's object. Never worklist this article for an unaffixed source file with no namespace declaration. +- The app has no assignable permission set covering its setup and usage paths, omits visible object/tabledata grants, or requires `SUPER` for normal operation — `permission-sets-cover-setup-and-usage-without-super`. Require repository-level app context; one isolated permission-set object cannot prove complete coverage. + +Before emitting an affix finding, compare every owned object name and every member added to another publisher's object against the configured `mandatoryAffixes`/`mandatoryPrefix`. A matching prefix or suffix is compliant. Do not flag an `ABC`-prefixed object or an `ABC`-suffixed extension member when `ABC` is the configured affix. - For BC v27 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent — `keep-copilot-help-url-to-two-path-levels`. 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`. @@ -58,7 +62,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - 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 change violates an AppSource submission requirement; otherwise 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. +- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. Set `confidence` to: diff --git a/microsoft/skills/review/al-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md index 0af5abc..82aeda0 100644 --- a/microsoft/skills/review/al-breaking-changes-review.md +++ b/microsoft/skills/review/al-breaking-changes-review.md @@ -39,10 +39,22 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially codeunits, tables, and table extensions that expose procedures, fields, or events to other apps, and any member whose access is being widened. - The changed procedures, fields, and triggers, weighted toward non-`local` procedures, published table fields, event publishers, and any member whose signature, access modifier, or obsolete state is being altered. -- Tokens extracted from the diff that relate to API stability and deprecation (`signature`, `parameter`, `return`, `var`, `Obsolete`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `Pending`, `Removed`, `CLEAN`, `SecretText`, `token`, `internal`, `local`, `public`, `protected`, `Scope`). +- Tokens extracted from the diff that relate to API stability and deprecation (`signature`, `parameter`, `return`, `var`, `Obsolete`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `Pending`, `Removed`, `CLEAN`, `SecretText`, `token`, `internal`, `local`, `public`, `protected`, `Scope`, `namespace`, `using`, `AS0007`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. +The following targeted checks cover every current `breaking-changes` article: + +- A helper or object changes between `local`, `internal`, `protected`, or public access, or a new implementation detail is exposed without a supported-API reason — `choose-access-modifiers-deliberately`. +- A public member is removed or replaced without first going through the `[Obsolete]` lifecycle — `deprecate-public-members-with-the-obsolete-lifecycle`. +- A published procedure changes parameter count/order/type/name, `var`, return type, or array shape instead of preserving the old signature and adding an overload — `do-not-change-published-procedure-signatures`. +- A public procedure/event/interface exposes a credential or other sensitive value through `Text` or an externally callable contract — `do-not-expose-sensitive-data-through-public-api`. +- Code already marked obsolete is expanded with new behavior instead of routing new callers to its replacement — `do-not-modify-code-already-marked-obsolete`. +- A shipped table field is deleted, renamed, renumbered, or replaced without retaining the original field as `ObsoleteState = Pending` and migrating its data — `obsolete-table-fields-instead-of-deleting-them`. This owns AS0005 field-name changes; do not substitute the namespace article. +- A published object's namespace changes between the base and changed source while its identity otherwise remains — `namespace-is-part-of-published-object-identity`. Do not apply it to a new, unshipped object or to an ordinary object-name change with no namespace change. + +For `obsolete-table-fields-instead-of-deleting-them`, compare the baseline ID and name before emitting. When the original field remains under the same ID and name with `ObsoleteState = Pending`, and the replacement uses a new ID, the change follows the rule and must not be flagged. + 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 breaking-changes knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable breaking-changes knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. @@ -53,7 +65,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - 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. 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`. +- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. Set `confidence` to: diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index a7cb8d9..9b3f934 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -24,6 +24,7 @@ sub-skills: - microsoft/skills/review/al-web-services-review.md - microsoft/skills/review/al-testing-review.md - microsoft/skills/review/al-data-modeling-review.md + - microsoft/skills/review/al-query-review.md - microsoft/skills/review/al-appsource-review.md - microsoft/skills/review/al-telemetry-review.md --- @@ -64,10 +65,12 @@ The worklist is the list of sub-skills judged relevant by the previous step. Eve The Action step is a sequence of **discrete iterations**, not one combined generation. The contract requires the super-skill to invoke each sub-skill in turn and then perform a self-review pass. Concretely this means: +- **Isolate leaf invocations when the host supports it.** For fast/small models, each sub-skill SHOULD run in a fresh model call or child context containing only the task input, READ/DO contracts, the leaf instructions, a domain-filtered slice of the current knowledge index, and articles that leaf worklists. Preserve each index row's exact `path`; the leaf must copy references from that slice. The coordinator then collects the resulting JSON. This is the preferred fast-model profile: it bounds context, prevents later leaves from being skipped as attention is exhausted, and removes any reason to synthesize article paths. - Treat each sub-skill in the worklist as its own pass: read the sub-skill's instructions, apply its Source → Relevance → Worklist → Action steps to the orchestrator-supplied inputs, and produce that sub-skill's complete findings-report before moving on. - Do not collapse multiple sub-skills into one shared reasoning step. Each sub-skill has a distinct knowledge subset and a distinct evaluation procedure; sharing one rolled-up scan dilutes per-skill attention and causes leaves to silently underreport (this has been observed in production: leaf skills returned empty `findings[]` while their standalone runs against the same diff produced multiple matches). - The agent self-review pass is its own final iteration. Begin it only after every sub-skill in the worklist has completed and its sub-result is recorded. - Sub-skills are independent: re-walking the diff once per sub-skill is correct and expected. The output schema accommodates this — `sub-results` carries one entry per sub-skill, each a complete findings-report. +- When isolated calls are unavailable and the current model cannot finish every leaf within its budget, return `partial` with completed `sub-results` and name the first unevaluated sub-skill in `outcome-reason`. Never silently mark the remaining leaves clean. ### Roll up sub-skill findings @@ -76,7 +79,8 @@ For each sub-skill in the worklist, executed one at a time per the discipline ab 1. Invoke the sub-skill with the orchestrator's inputs, passing only the subset each sub-skill declares in its `inputs`. 2. Capture the sub-skill's complete findings-report verbatim and append it to `sub-results`. 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` and preserving each finding's optional `domain` field verbatim, including its absence. 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. +4. Otherwise, compare each entry from the sub-skill's `findings[]` with findings already rolled up. Two findings are duplicates when they point to the same file and overlapping line/range and prescribe materially the same correction, even when their knowledge-file IDs differ. Merge duplicates instead of appending both: keep the more specific domain owner, preserve that finding's optional `domain` field verbatim (including its absence), use its reference as `references[0]` and therefore as `id`, append the other references as supporting references, keep the highest severity and confidence justified by either report, and preserve one self-contained message. Article and leaf ownership notes decide specificity; do not choose by execution order. +5. Append each non-duplicate finding, setting `from-sub-skill` to the sub-skill's `skill.id` and preserving its optional `domain` field verbatim, including its absence. 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. ### Agent self-review pass @@ -118,6 +122,8 @@ Aggregate `summary.counts` and `summary.coverage` as the sums across invoked sub Derive `outcome` using the DO rollup rules. `outcome-reason` is populated for `partial` and `failed` and SHOULD summarize per-sub-skill state, for example: *"al-security-review failed (tool timeout); al-performance-review completed."* +Before emitting the rollup, apply DO's reference-integrity gate to every nested and top-level finding. Every knowledge-backed ID/reference path must exist in the live checkout, must have been opened by the producing leaf, and must be copied verbatim rather than synthesized. Treat a sub-result containing an unverifiable citation as failed and exclude its findings from the top-level rollup. + ## Output Output conforms to the DO output contract, extended with `sub-results` and `skipped-sub-skills`. A populated example — both leaves ran, each produced findings: diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index ca10e73..2386613 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-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 `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, and document or journal lines that reference a master. - The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, and posting validation. -- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`). +- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. @@ -50,6 +50,8 @@ The following targeted checks cover every current `data-modeling` article. Treat - BC v22 or later code introduces or retains `NoSeriesManagement`, `InitSeries`, `SelectSeries`, or `SetSeries`, or number assignment/manual-entry checks do not use codeunit `"No. Series"` methods such as `GetNextNo`, `IsManual`, or `TestManual` — `use-no-series-codeunit-not-noseriesmanagement`. - A master gains or changes `Blocked`, or a document line, journal line, reference-field `OnValidate`, or posting routine uses that master without `TestField(Blocked, false)` at the point of use; also cue when the check is placed only in the master's own triggers — `check-blocked-in-referencing-code-not-in-master`. - A master table adds or changes `Last Date Modified`, `OnModify`, or `OnRename`, but the non-editable field is not assigned `Today()` in both triggers — `set-last-date-modified-in-onmodify-and-onrename`. +- A `tableextension` appends a conditional `TableRelation` as if it overrides an earlier unconditional relation, or relation branches are otherwise designed without accounting for additive top-down evaluation — `table-relation-extensions-are-additive-and-top-down`. +- A `Media` or `MediaSet` field is assigned directly between different table types or different field IDs instead of registering each shared item with `MediaSet.Insert` — `share-mediaset-items-with-insert-not-field-assignment`. 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`. @@ -61,7 +63,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - 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 model can create ambiguous setup state, incompatible business identifiers, or silently stale synchronization data; otherwise 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. +- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. Set `confidence` to: diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index 3b7d6cf..39851ac 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-review.md @@ -44,6 +44,15 @@ Narrow the relevant files to the subset that applies to the changes under review A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. +The following targeted checks cover every current `error-handling` article: + +- `[ErrorBehavior(ErrorBehavior::Collect)]`, `ErrorInfo.Collectible`, `HasCollectedErrors`, `GetCollectedErrors`, or `ClearCollectedErrors` is added or changed, especially when errors are collected without later surfacing/clearing them — `collect-validation-errors-with-errorbehavior`. +- Developer-only invariant text is raised with default client visibility, or a user-actionable validation is hidden as `ErrorType::Internal` — `errortype-internal-vs-client-for-diagnostics`. +- `FieldError` receives a complete capitalized sentence, repeats the field caption/value, or ends the predicate with punctuation — `fielderror-default-message-logic`. +- An unguarded `FieldError` is used as though it performed a comparison, or `TestField` is forced onto a complex rule needing a tailored predicate — `fielderror-vs-testfield`. +- A resolved call target is marked `[TryFunction]` but the call is a standalone statement whose Boolean result is ignored — `ignored-tryfunction-return-disables-try-semantics`. This call-site rule supersedes the performance TryFunction article unless writes and rollback expectations are also visible. +- A plain `Error` represents a known actionable correction that can be expressed through `ErrorInfo` actions/navigation, or an `ErrorInfo` omits the context needed for that action — `prefer-errorinfo-for-actionable-errors`. + 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 error-handling knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable error-handling knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. @@ -54,7 +63,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - 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. 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`. +- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. Set `confidence` to: diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index ef4be0e..de2700c 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-review.md @@ -72,7 +72,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - 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. 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`. +- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. Set `confidence` to: diff --git a/microsoft/skills/review/al-interfaces-review.md b/microsoft/skills/review/al-interfaces-review.md index a52d451..0031e8f 100644 --- a/microsoft/skills/review/al-interfaces-review.md +++ b/microsoft/skills/review/al-interfaces-review.md @@ -55,13 +55,15 @@ The following targeted checks map diff signals to specific `interfaces` articles - A method added directly to an interface that exists in the baseline, instead of adding a BC25+ interface that `extends` it or a versioned sibling for older targets — `extend-published-interfaces-dont-edit-them`. - A declared enum value with no `Implementation` and no enum-level `DefaultImplementation` — `set-defaultimplementation-on-enum`. +For `set-defaultimplementation-on-enum`, inspect the complete containing enum before emitting. An enum-level `DefaultImplementation = = ;` conclusively covers every declared value that omits its own `Implementation`; do not flag such a value and do not replace the intentional fallback with a per-value mapping. + ## 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. 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`. +- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. Set `confidence` to: diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index f27beb3..3262179 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -39,15 +39,18 @@ 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, SetAutoCalcFields, CalcSums, FlowField access, Commit calls, checkpoint helpers, record copying, RecordRef conversion, Modify/Delete calls, or cross-table navigation. -- Tokens extracted from the diff that relate to data access and hot-path costs (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`). +- Tokens extracted from the diff that relate to data access and hot-path costs (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`, `FlowField`, `Visible`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. Apply these targeted cues even when simple token overlap would rank the article below the worklist cutoff: - Worklist `use-setautocalcfields-for-per-row-flowfields.md` when a record loop calls `CalcFields`, or when every row reads the same FlowField for a comparison, branch, or per-record action. Worklist `calcsums-instead-of-calcfields-in-loop.md` instead when the loop only accumulates one set total. +- Worklist `hidden-flowfields-still-calculate-before-bc26-opt-in.md` when a page control directly sources a FlowField and sets `Visible = false` or a visibility expression. Suppress it when the target is known to have BC26's **Calculate only visible FlowFields** feature enabled, or when the FlowField is cheap and intentionally preloaded. - Worklist `avoid-commit-inside-loops.md` only when `Commit()` is inside a record-iteration body or a helper invoked once per row. Do not match one `Commit()` after a bounded checkpoint helper returns, a `Commit()` outside iteration, or comments and documentation that merely mention commits. - Worklist `avoid-cloning-records-before-modify-delete-in-loops.md` when an iteration calls `Copy` or `RecordRef.GetTable` before `Modify`/`Delete`, or passes the iterated record without `var` to a helper that writes that record. Do not worklist it from `Modify`, `Delete`, or `RecordRef` alone; exclude a direct write on the iterator, a read-only copy, a temporary record, a different target table, and a `RecordRef` opened and iterated directly. +- Worklist `use-tryfunction-for-error-catching-not-rollback.md` only when writes occur inside a try method and the code or surrounding flow expects an error to roll them back. A bare try-method call whose Boolean result is ignored belongs exclusively to `error-handling/ignored-tryfunction-return-disables-try-semantics.md`; do not worklist the performance article from that call shape alone. +- For `LockTable` in a pure read helper, select exactly one owner. Use `do-not-locktable-in-read-only-procedure.md` when the helper needs no stronger isolation and should remove the lock. Use `prefer-readisolation-over-locktable-for-reads.md` instead when the code explicitly requires committed-read semantics and `ReadIsolation` is the replacement. Never emit both findings for the same call. These targeted inclusions and exclusions override generic token overlap. Do not retain an excluded article solely because the diff contains one of its keywords. @@ -61,7 +64,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - 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 query timeouts or transaction size limits). 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`. +- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. Set `confidence` to: diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index 0988622..edbf197 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -45,6 +45,11 @@ Narrow the relevant files to the subset that applies to the changes under review A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Apply the topic-specific gates above after this overlap check; in particular, bare `Message` and `DataClassification` tokens cannot admit ErrorInfo guidance. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. +Apply API ownership before fuzzy ranking: + +- A `Session.LogMessage` message built with `StrSubstNo` or concatenation from customer, employee, filename, document, or other identifying values belongs to `no-pii-in-telemetry-message-string.md`. +- `avoid-strsubstno-prebuild-before-error.md` applies only when `StrSubstNo` or concatenation supplies the first argument to `Error(...)`. Never apply it to `Session.LogMessage`, `FeatureTelemetry`, or another telemetry API. + 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. @@ -55,7 +60,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - 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`. +- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. Set `confidence` to: diff --git a/microsoft/skills/review/al-query-review.md b/microsoft/skills/review/al-query-review.md new file mode 100644 index 0000000..c4ea895 --- /dev/null +++ b/microsoft/skills/review/al-query-review.md @@ -0,0 +1,56 @@ +--- +kind: action-skill +id: al-query-review +version: 1 +title: AL Query review +description: Reviews AL Query objects and Query instance usage against BCQuality guidance. +inputs: [pr-diff, file-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL Query review + +Reviews AL source changes against the `query` knowledge domain in BCQuality. This is a leaf action skill composed by `al-code-review`. + +## Source + +Read `knowledge-index.json` once and take entries whose `domain` is `query` across enabled layers. Open an article body only after it enters the Worklist. If the index is unavailable, discover `*/knowledge/query/*.md` by path. + +## Relevance + +Apply READ's frontmatter matching rules against the task context. Use the target version from `app.json` when available and `[al]` for technologies. Retain conditionally applicable files only when configured; cap resulting confidence at `medium` and name every unknown dimension in the finding message. + +Return `not-applicable` when the input contains no Query object declaration and no Query variable method call. + +## Worklist + +Match relevant entries against changed `query` objects, variables typed as `Query`, and the tokens `QueryType`, `dataitem`, `column`, `DataItemLink`, `SqlJoinType`, `SetFilter`, `SetRange`, `Open`, `Read`, `Close`, and `Clear`. + +The following targeted checks cover every current `query` article: + +- `SetFilter` or `SetRange` occurs after `Open()` without a new `Open()` before the next `Read()` — `set-query-filters-before-open`. +- An already-open query is opened again as if that advanced the cursor, or a query variable is reused for an independent operation without `Clear` even though old filters must not carry over — `reopening-query-resets-cursor-but-keeps-filters`. + +Resolve layer conflicts per READ. When no query knowledge exists, emit `no-knowledge`; when knowledge exists but no article matches the changed Query usage, emit `completed` with no findings. + +## Action + +Evaluate every worklist article against the diff's Query call order and surrounding control flow. + +- Emit `major` for an unambiguous Anti Pattern that can close the dataset, restart processing, or retain an unintended filter. +- Emit `minor` when code contradicts a Best Practice but the resulting behavior depends on unseen control flow. +- Do not emit applicability-only information. A Query article produces a finding only when the changed code violates its normative guidance. + +Set confidence to `high` for a locally visible call sequence and `medium` when aliases, helper calls, or missing context obscure the sequence. Domain-scoped agent findings follow DO's precision bar and remain capped at `minor`/`medium`. + +Provide `suggested-code` only when moving a filter before `Open()` or adding `Clear` is a complete, local, unambiguous replacement. Otherwise set `suggested-code-omission-reason`. + +Outcome selection follows DO: `completed`, `no-knowledge`, `not-applicable`, `partial`, or `failed`. + +## Output + +Output conforms to the DO findings-report contract. Every finding this skill emits MUST set `findings[].domain` to `"Query"`. diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index 23a93cf..12956bd 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -39,10 +39,17 @@ Narrow the relevant files to the subset that applies to the changes under review - 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`). +- Tokens extracted from the diff that relate to security concerns (`IsolatedStorage`, `SetEncrypted`, `OAuth2`, `SecretText`, `Unwrap`, `NonDebuggable`, `Password`, `Token`, `HttpClient`, `Uri`, `AreURIsHaveSameHost`, `IsValidURIPattern`, `RecordRef`, `RecordId`, `TransferFields`, `Codeunit.Run`, `Access = Internal`, `internalsVisibleTo`, `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 the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. +Always worklist `internal-access-is-not-a-security-boundary.md` when changed comments or code rely on `Access = Internal` or `internalsVisibleTo` to protect a sensitive operation, or an internal `OnRun` codeunit performs privileged work without an independent authorization boundary. Do not flag `internal` used only to keep implementation details out of the supported API. + +For secret values, select the most specific sink owner: + +- When a `Text`/`Code` credential is declared, passed, returned, or unwrapped without a visible HTTP URI/header/body sink, use `secrettext-for-credentials.md`. +- When that value is interpolated into a URI, authorization header, or HTTP body and sent through `HttpClient`, use `secrettext-with-httpclient.md` as the primary finding. It supersedes the generic credential-type article at that location; keep the latter only as a supporting reference when useful. + 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 security knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable security knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. @@ -53,7 +60,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - 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 secret-handling rules, permission-model invariants, or data-protection 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`. +- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. Set `confidence` to: diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index 37b9a15..c570a08 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -45,6 +45,13 @@ Narrow the relevant files to the subset that applies to the changes under review A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object or declaration. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. +Do not worklist `temporary-variable-temp-prefix.md` for an event publisher parameter. `events/prefix-temporary-record-event-parameters-with-temp.md` is the exclusive owner of that shape. + +Apply these high-signal mappings before fuzzy topic ranking: + +- A `Label` or `TextConst` contains multiple or ambiguous placeholders but has no `Comment`, or its Comment does not explain every placeholder — `label-comment-explains-placeholders.md`. A single placeholder whose meaning is explicit in the text, such as `Customer %1`, is allowed without a Comment and must not be flagged. +- `function-call-parentheses-required.md` applies only to a zero-argument invocation written without `()`. Never worklist it from an invocation that already has parentheses or supplies arguments, including `Error(Label, Arg1, Arg2)`. + 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. diff --git a/microsoft/skills/review/al-telemetry-review.md b/microsoft/skills/review/al-telemetry-review.md index e4169a2..4a758f0 100644 --- a/microsoft/skills/review/al-telemetry-review.md +++ b/microsoft/skills/review/al-telemetry-review.md @@ -39,10 +39,21 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL objects and procedures — especially telemetry wrapper codeunits, feature lifecycle instrumentation, error logging, integration diagnostics, and background/session processing. - Calls to `Session.LogMessage`, `Session.LogError`, or `FeatureTelemetry` methods, weighted toward the event ID, verbosity, data classification, custom dimensions, and `TelemetryScope` arguments. -- Tokens extracted from the diff that relate to telemetry (`Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, `TelemetryScope`, `ExtensionPublisher`, `All`, `Verbosity`, `DataClassification`, `CustomDimensions`, `Application Insights`, `LogUsage`, `LogError`, `LogUptake`, `Feature Uptake Status`). +- Telemetry infrastructure codeunits that implement `"Telemetry Logger"` or subscribe to `"Telemetry Loggers".OnRegisterTelemetryLogger`. +- Tokens extracted from the diff that relate to telemetry (`Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, `TelemetryScope`, `ExtensionPublisher`, `All`, `Verbosity`, `Critical`, `Error`, `Warning`, `Normal`, `Verbose`, `DataClassification`, `CustomDimensions`, `Application Insights`, `Telemetry Logger`, `Telemetry Loggers`, `OnRegisterTelemetryLogger`, `LogUsage`, `LogError`, `LogUptake`, `Feature Uptake Status`, `Discovered`, `Set up`, `Used`, `Undiscovered`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no telemetry-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. +The following targeted checks cover every current `telemetry` article. Treat each as a candidate-selection cue: + +- A `Session.LogMessage` event ID is empty, generated dynamically, reused for different events, changed on an existing event, or uses a placeholder such as `0000`, `1234`, `TODO`, or `XX0000` — `telemetry-event-id-stable-unique`. +- `TelemetryScope::All` is used for a clearly publisher-only implementation diagnostic, or `ExtensionPublisher` hides a clearly customer-actionable failure from environment telemetry — `choose-telemetry-scope-by-audience`. Do not infer the audience when the message and surrounding branch are ambiguous. +- An explicit failure branch logs through `Session.LogMessage` with `Verbosity::Normal` or `Verbose`, or a non-error event is inflated to `Error`/`Critical` — `match-verbosity-to-signal-severity`. +- A new feature's visible uptake calls skip `Discovered` or `Set up`, jump directly to `Used`, or use inconsistent feature-name literals across states — `feature-uptake-transitions-in-order`. Require repository-level lifecycle evidence; one isolated call is not proof. +- `FeatureTelemetry.LogUsage` runs before success is known or on a failure path — `feature-usage-only-after-success`. `LogUptake(...Used)` records an attempt and is not this anti-pattern. +- A complete app or app family uses `FeatureTelemetry` without any registered `"Telemetry Logger"`, or registers more than one implementation for the same publisher — `register-one-telemetry-logger-per-publisher`. Absence requires repository/app-family context. +- A custom-dimension key contains spaces or non-PascalCase naming, or an existing event ID changes/removes a shipped key — `keep-custom-dimension-schema-stable`. Treat naming alone as advisory; the schema change is the compatibility defect. + 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 telemetry knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable telemetry knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. @@ -53,7 +64,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - 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; otherwise 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. +- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. Set `confidence` to: diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index 01f8f16..2483f12 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-review.md @@ -38,14 +38,16 @@ 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 = Test`, test runner codeunits with `TestIsolation`, test libraries, and codeunits that define UI handlers. -- The changed methods and attributes, weighted toward `[Test]`, `[TransactionModel(...)]`, `[HandlerFunctions(...)]`, handler attributes, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, fixture initialization, and test-library calls. -- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `TestIsolation`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Init`, `Insert`). +- The changed methods and attributes, weighted toward `[Test]`, `[TransactionModel(...)]`, `[TestPermissions(...)]`, `[HandlerFunctions(...)]`, handler attributes, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, fixture initialization, and test-library calls. +- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `Subtype = TestRunner`, `TestIsolation`, `TestPermissions`, `Restrictive`, `NonRestrictive`, `Disabled`, `Permissions Mock`, `Library - Lower Permissions`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Init`, `Insert`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no testing-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. The following targeted checks cover every current `testing` article. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action. -- A method in a `Subtype = Test` codeunit adds or changes `[TransactionModel(...)]`, exercises code that calls `Commit`, defaults broadly to `AutoCommit`, or uses `AutoCommit` (or exercises a path that calls `Commit`) without a `TestIsolation`-enabled runner — `transactionmodel-attribute-governs-test-transactions`. Do not worklist this article solely because an ordinary `AutoRollback` or read-only test has no `TestIsolation` runner. +- A method in a `Subtype = Test` codeunit adds or changes `[TransactionModel(...)]`, exercises code that calls `Commit` under `AutoRollback`, defaults broadly to `AutoCommit`, or chooses `None` for a writing test — `transactionmodel-attribute-governs-test-transactions`. +- An `AutoCommit` test runs under a `Subtype = TestRunner` codeunit that omits `TestIsolation` or sets it to `Disabled`, leaving committed data between tests — `testisolation-belongs-on-the-test-runner`. Require runner/repository context; a standalone test file cannot prove which runner executes it. +- A permission-sensitive test uses `TestPermissions = Disabled`, claims to test a restricted user without `"Permissions Mock"`/`"Library - Lower Permissions"`, or declares `[TestPermissions(...)]` without applying that context — `permission-tests-must-lower-the-execution-context`. - Test fixture code manually calls `Init`/`Insert`, invents keys or prerequisite records, or bypasses available `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, or equivalent library codeunits — `use-library-codeunits-for-test-fixtures`. - `asserterror` is added or changed without a following `Assert.ExpectedError`, `Assert.ExpectedErrorCode`, or a purpose-built assertion such as `ExpectedTestFieldError` — `asserterror-needs-expectederror-and-code`. - A test path raises UI, `[HandlerFunctions(...)]` does not exactly match the invoked handlers, a handler hardcodes replies instead of using enqueue/dequeue expectations, or `LibraryVariableStorage.Clear`/`AssertEmpty` is missing — `ui-handlers-in-tests`. @@ -60,7 +62,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - 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 test can pass while verifying the wrong behavior or can leave committed data that contaminates later tests; otherwise 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. +- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. Set `confidence` to: diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md index 2cecf0c..879e788 100644 --- a/microsoft/skills/review/al-upgrade-review.md +++ b/microsoft/skills/review/al-upgrade-review.md @@ -56,7 +56,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - 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. +- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. Set `confidence` to: diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md index 551dce5..cfa6fdd 100644 --- a/microsoft/skills/review/al-web-services-review.md +++ b/microsoft/skills/review/al-web-services-review.md @@ -54,7 +54,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - 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. 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`. +- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. For API parts whose parent declares `ODataKeyFields = SystemId`, detect a child foreign key linked to a parent business field instead of `Field(SystemId)`. Do not apply the SystemId-link rule to APIs intentionally keyed by another field. Omitted `Multiplicity` is valid and means the documented default 1:N collection; never report omission alone. Report an explicit `ZeroOrOne` only when the visible contract clearly intends a collection or deep insert, and report an explicit `Many` only when it clearly intends a singleton. Singleton metadata requires an explicit `ZeroOrOne`; do not infer singleton intent from naming alone. For webhook eligibility, detect `QueryType = API`, `SourceTableTemporary = true`, composite `ODataKeyFields` (including an omitted property when a visible source primary key is composite), Job Queue Entry, and visible system-table sources; do not infer an unknown table number. For lifecycle code, require both create and renew paths to use a handler that returns the query-string `validationToken` verbatim with `200 OK`, and flag renewal scheduling that assumes subscriptions are permanent instead of using `expirationDateTime`. Do not emit generic HTTP or REST advice. diff --git a/skills/bcquality-al-review/SKILL.md b/skills/bcquality-al-review/SKILL.md index a11f891..20b2417 100644 --- a/skills/bcquality-al-review/SKILL.md +++ b/skills/bcquality-al-review/SKILL.md @@ -63,11 +63,19 @@ plugin-root environment variable, prefer it. `microsoft/skills/review/al-code-review.md`. For each dispatched skill, read the file and execute its Source → Relevance → Worklist → Action steps, reading `PLUGIN_ROOT/skills/read.md` and `PLUGIN_ROOT/skills/do.md` on demand. + When `al-code-review` composes its leaves and the host supports child contexts or + separate model calls, run each leaf in an isolated context and roll up the returned + JSON. Pass each call the exact index rows for that leaf's domain so references can + be copied verbatim. This is the preferred execution profile for fast/small models; + do not force one generation to retain all domain knowledge at once. 4. **Emit findings.** Produce the rolled-up findings report in the DO output contract, including each review finding's producer-supplied `domain` label (`outcome`, `findings`, `references`, `confidence`, `suppressed`). Do not invent a different shape; downstream consumers parse the DO contract without skill-specific logic. + Apply DO's reference-integrity gate before returning: every knowledge-backed path + must exist in the installed tree, must have been opened in full, and must be copied + verbatim. Never synthesize a plausible article slug. If Entry returns `no-match` or `failed`, return the dispatch record unchanged so the caller can log the reason. diff --git a/skills/do.md b/skills/do.md index bc7780e..9de11a4 100644 --- a/skills/do.md +++ b/skills/do.md @@ -171,6 +171,8 @@ Consumers that render output MAY treat agent findings differently from knowledge **`findings[].message`** — human-readable explanation of the finding. Single short paragraph. No markdown formatting assumptions. +**Applicability is not a finding.** Loading an article into the worklist only means its rule must be evaluated. If the changed code does not violate the article's normative guidance, emit nothing for that article. An `info` finding still requires a concrete observation defined by the article; skills MUST NOT use `info` to list guidance that merely happened to be relevant. + **`findings[].location`** — optional. When present: - `file` MUST be a repo-relative path using forward slashes. @@ -186,6 +188,15 @@ Findings without a `location` are permitted (for example, repository-wide observ 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. +**Reference-integrity gate (mandatory).** A knowledge-backed finding may cite only a path copied verbatim from the current knowledge index or from a file discovered by the index fallback, and the skill must have opened that exact file in full before citing it. Never construct a plausible slug or infer a path from a topic name. Immediately before emitting the JSON document: + +1. Verify every non-empty `references[].path` exists in the live checkout and was opened during this skill run. +2. Verify every citation-based `findings[].id` exactly equals `references[0].path`. +3. Remove any candidate that cannot satisfy both checks; it is not a knowledge-backed finding. Do not convert it into an agent finding merely to preserve it. +4. If reference integrity cannot be checked reliably, return `outcome: "failed"` rather than emitting fabricated or unverified citations. + +This gate applies independently to every leaf result and again to a super-skill's rolled-up result. + **`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, or the literal string `"agent"` for an agent finding the super-skill produced from its own cross-cutting reasoning. Absent on findings emitted directly by a leaf skill — including agent findings the leaf emits within its own domain, which appear in the leaf's own report without this field. diff --git a/skills/write.md b/skills/write.md index 754fe1e..a9e3c31 100644 --- a/skills/write.md +++ b/skills/write.md @@ -85,7 +85,10 @@ Before opening a pull request: - No fenced code blocks. - File is under 100 lines. - File covers one concern. +- Frontmatter `domain` exactly matches the containing domain folder. - File is in the correct layer and domain folder. - Name is kebab-case and descriptive. +- Every companion sample is referenced by filename from the article, and every referenced sample exists. +- Every review-leaf domain has at least one article with both `.good.al` and `.bad.al` companions; the evaluation harness derives positive and clean controls from that convention automatically. Agents scaffolding new files SHOULD run this checklist programmatically before emitting the file. diff --git a/tools/Test-ReviewFixtures.ps1 b/tools/Test-ReviewFixtures.ps1 new file mode 100644 index 0000000..3f3f144 --- /dev/null +++ b/tools/Test-ReviewFixtures.ps1 @@ -0,0 +1,483 @@ +<# +.SYNOPSIS + Validates and prepares the BCQuality AL review evaluation corpus. + +.DESCRIPTION + CI uses the static validation path to prove every registered AL review leaf + has one positive and one clean control, every fixture/reference exists, and + the manifest remains internally consistent. + + For an actual model run, -PrepareDirectory copies inputs to neutral names and + emits review-request.json without expected answers. After the model writes a + result matching evaluation/README.md, -ResultsPath scores exact knowledge-ID + recall, clean-control rate, and unexpected findings. +#> +[CmdletBinding()] +param( + [string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')), + [string] $ManifestPath, + [string] $PrepareDirectory, + [string] $ResultsPath, + [string] $ResultsDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$Root = (Resolve-Path -LiteralPath $Root).Path +if ($ResultsPath -and $ResultsDirectory) { + throw 'Specify either ResultsPath or ResultsDirectory, not both.' +} +if (-not $ManifestPath) { + $ManifestPath = Join-Path $Root 'evaluation/review-fixtures.json' +} +if (-not (Test-Path -LiteralPath $ManifestPath)) { + throw "Review fixture manifest not found: $ManifestPath" +} + +$manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json +$problems = [System.Collections.Generic.List[string]]::new() + +function Get-ModelCaseId { + param([string] $ManifestId) + + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($ManifestId) + $hash = $sha.ComputeHash($bytes) + $token = ([System.BitConverter]::ToString($hash) -replace '-', '').Substring(0, 8).ToLowerInvariant() + return "case-$token" + } finally { + $sha.Dispose() + } +} + +function Get-RankedArticles { + param( + [object[]] $Articles, + [string] $CaseText, + [int] $Limit = 10 + ) + + if ($Articles.Count -le $Limit) { + return @($Articles) + } + + $normalized = (($CaseText.ToLowerInvariant() -replace '[^a-z0-9]+', ' ') -replace '\s+', ' ').Trim() + $compact = $normalized -replace ' ', '' + $ranked = foreach ($article in $Articles) { + $score = 0 + foreach ($keyword in @($article.keywords)) { + $keywordText = ([string]$keyword).ToLowerInvariant() + $keywordCompact = $keywordText -replace '[^a-z0-9]+', '' + if ($keywordCompact -and $compact.Contains($keywordCompact)) { + $score += 8 + } + foreach ($part in @($keywordText -split '[^a-z0-9]+')) { + if (($part.Length -ge 4) -and ($normalized -match "(^| )$([regex]::Escape($part))( |$)")) { + $score += 1 + } + } + } + $topicText = "$($article.title) $($article.description) $($article.path)".ToLowerInvariant() + foreach ($term in @($normalized -split ' ' | Where-Object Length -ge 5 | Sort-Object -Unique)) { + if ($topicText.Contains($term)) { + $score += 0.25 + } + } + [pscustomobject]@{ score = $score; path = [string]$article.path; article = $article } + } + + return @( + $ranked | + Sort-Object @{ Expression = 'score'; Descending = $true }, @{ Expression = 'path'; Descending = $false } | + Select-Object -First $Limit | + ForEach-Object article + ) +} + +if ($manifest.version -ne 2) { + $problems.Add("Unsupported manifest version: $($manifest.version)") | Out-Null +} +if ($manifest.selection -ne 'first-paired-al-article') { + $problems.Add("Unsupported selection strategy: $($manifest.selection)") | Out-Null +} +if (([double]$manifest.minimumExpectedRecall -lt 0) -or ([double]$manifest.minimumExpectedRecall -gt 1)) { + $problems.Add('minimumExpectedRecall must be between 0 and 1.') | Out-Null +} +if (([double]$manifest.minimumCleanRate -lt 0) -or ([double]$manifest.minimumCleanRate -gt 1)) { + $problems.Add('minimumCleanRate must be between 0 and 1.') | Out-Null +} + +$leafDomains = @( + Get-ChildItem -LiteralPath (Join-Path $Root 'microsoft/skills/review') -File -Filter 'al-*-review.md' | + Where-Object Name -ne 'al-code-review.md' | + ForEach-Object { $_.BaseName -replace '^al-', '' -replace '-review$', '' } | + Sort-Object -Unique +) + +$overrides = @{} +if ($manifest.PSObject.Properties.Name -contains 'overrides') { + foreach ($property in $manifest.overrides.PSObject.Properties) { + $overrides[$property.Name] = $property.Value + } +} +foreach ($overrideDomain in $overrides.Keys) { + if ($leafDomains -notcontains $overrideDomain) { + $problems.Add("Override domain '$overrideDomain' has no registered al-$overrideDomain-review leaf.") | Out-Null + } +} + +$caseList = [System.Collections.Generic.List[object]]::new() +foreach ($domain in $leafDomains) { + $knowledgeDirectory = Join-Path $Root "microsoft/knowledge/$domain" + if (-not (Test-Path -LiteralPath $knowledgeDirectory -PathType Container)) { + $problems.Add("${domain}: no Microsoft knowledge directory exists.") | Out-Null + continue + } + + $override = if ($overrides.ContainsKey($domain)) { $overrides[$domain] } else { $null } + $selectedArticle = $null + if ($override -and ($override.PSObject.Properties.Name -contains 'article')) { + $articleName = [string]$override.article + if ($articleName.EndsWith('.md')) { + $articleName = [System.IO.Path]::GetFileNameWithoutExtension($articleName) + } + $candidate = Join-Path $knowledgeDirectory "$articleName.md" + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + $selectedArticle = Get-Item -LiteralPath $candidate + } else { + $problems.Add("${domain}: override article does not exist: $articleName.md") | Out-Null + } + } else { + $selectedArticle = Get-ChildItem -LiteralPath $knowledgeDirectory -File -Filter '*.md' | + Sort-Object Name | + Where-Object { + (Test-Path -LiteralPath (Join-Path $knowledgeDirectory "$($_.BaseName).good.al") -PathType Leaf) -and + (Test-Path -LiteralPath (Join-Path $knowledgeDirectory "$($_.BaseName).bad.al") -PathType Leaf) + } | + Select-Object -First 1 + } + if (-not $selectedArticle) { + $problems.Add("${domain}: no article has both .good.al and .bad.al companion samples.") | Out-Null + continue + } + + $articlePath = "microsoft/knowledge/$domain/$($selectedArticle.Name)" + $context = if ($override -and ($override.PSObject.Properties.Name -contains 'context')) { + [string]$override.context + } else { + $null + } + foreach ($kind in 'bad', 'good') { + $case = [pscustomobject]@{ + id = "$domain-$kind" + domain = $domain + input = "microsoft/knowledge/$domain/$($selectedArticle.BaseName).$kind.al" + expected = if ($kind -eq 'bad') { @($articlePath) } else { @() } + } + if ($context) { + $case | Add-Member -NotePropertyName context -NotePropertyValue $context + } + $caseList.Add($case) | Out-Null + } +} +$cases = @($caseList) + +$seenIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) +foreach ($case in $cases) { + $id = [string]$case.id + $domain = [string]$case.domain + $input = [string]$case.input + $expected = @($case.expected) + + if ([string]::IsNullOrWhiteSpace($id)) { + $problems.Add('Case with empty id.') | Out-Null + } elseif (-not $seenIds.Add($id)) { + $problems.Add("Duplicate case id: $id") | Out-Null + } + if ($leafDomains -notcontains $domain) { + $problems.Add("${id}: domain '$domain' has no registered al-$domain-review leaf.") | Out-Null + } + + $inputPath = Join-Path $Root $input + if (-not (Test-Path -LiteralPath $inputPath -PathType Leaf)) { + $problems.Add("${id}: input does not exist: $input") | Out-Null + } + if ($expected.Count -and $input -notmatch '\.bad\.[^.]+$') { + $problems.Add("${id}: positive case must use a .bad sample: $input") | Out-Null + } + if (-not $expected.Count -and $input -notmatch '\.good\.[^.]+$') { + $problems.Add("${id}: clean case must use a .good sample: $input") | Out-Null + } + + foreach ($reference in $expected) { + $referencePath = Join-Path $Root ([string]$reference) + if (-not (Test-Path -LiteralPath $referencePath -PathType Leaf)) { + $problems.Add("${id}: referenced article does not exist: $reference") | Out-Null + } + } + if ($expected.Count) { + $sampleSlug = ([System.IO.Path]::GetFileName($input) -replace '\.(?:good|bad)\.[^.]+$', '') + $primarySlug = [System.IO.Path]::GetFileNameWithoutExtension([string]$expected[0]) + if ($sampleSlug -ne $primarySlug) { + $problems.Add("${id}: primary expected article '$primarySlug' must match sample slug '$sampleSlug'.") | Out-Null + } + } +} + +foreach ($domain in $leafDomains) { + $domainCases = @($cases | Where-Object domain -eq $domain) + if (-not @($domainCases | Where-Object { @($_.expected).Count -gt 0 }).Count) { + $problems.Add("${domain}: no positive review fixture.") | Out-Null + } + if (-not @($domainCases | Where-Object { @($_.expected).Count -eq 0 }).Count) { + $problems.Add("${domain}: no clean control fixture.") | Out-Null + } +} + +if ($problems.Count) { + Write-Host "Review fixture validation FAILED ($($problems.Count) problem(s)):" -ForegroundColor Red + $problems | ForEach-Object { Write-Host " - $_" -ForegroundColor Red } + exit 1 +} + +if ($PrepareDirectory) { + $markerPath = Join-Path $PrepareDirectory '.bcquality-evaluation' + if (Test-Path -LiteralPath $PrepareDirectory) { + $existing = @(Get-ChildItem -LiteralPath $PrepareDirectory -Force) + if ($existing.Count -and -not (Test-Path -LiteralPath $markerPath -PathType Leaf)) { + throw "PrepareDirectory is not empty and is not a BCQuality evaluation directory: $PrepareDirectory" + } + if (Test-Path -LiteralPath $markerPath -PathType Leaf) { + Get-ChildItem -LiteralPath $PrepareDirectory -File | + Where-Object { + ($_.Name -like 'case*.al') -or + ($_.Name -eq 'review-request.json') -or + ($_.Name -like 'request-*.json') -or + ($_.Name -eq 'knowledge-index.json') -or + ($_.Name -like 'index-*.json') -or + ($_.Name -like 'result-*.json') + } | + Remove-Item -Force + } + } else { + New-Item -ItemType Directory -Force -Path $PrepareDirectory | Out-Null + } + Set-Content -LiteralPath $markerPath -Value 'BCQuality generated evaluation directory' -Encoding UTF8 + + $fullIndexPath = Join-Path $PrepareDirectory 'knowledge-index.json' + & (Join-Path $Root 'tools/Build-KnowledgeIndex.ps1') -BCQualityRoot $Root -IndexPath $fullIndexPath | Out-Null + $fullIndex = Get-Content -LiteralPath $fullIndexPath -Raw | ConvertFrom-Json + + $requestCases = [System.Collections.Generic.List[object]]::new() + $requestCasesByDomain = @{} + $manifestCaseByModelId = @{} + foreach ($case in $cases) { + $extension = [System.IO.Path]::GetExtension([string]$case.input) + $modelId = Get-ModelCaseId -ManifestId ([string]$case.id) + $neutralName = "$modelId$extension" + $sourceText = Get-Content -LiteralPath (Join-Path $Root ([string]$case.input)) -Raw + # Companion samples are human-facing and often label objects/comments as + # Good, Bad, or Anti-pattern. Strip full-line comments and neutralize those + # object-name tokens so model-facing fixtures do not reveal the expected + # outcome while preserving executable AL structure and references. + $neutralText = [regex]::Replace($sourceText, '(?m)^\s*//.*(?:\r?\n|$)', '') + $neutralText = [regex]::Replace($neutralText, '\b(?:Good|Bad)\b', 'Eval') + Set-Content -LiteralPath (Join-Path $PrepareDirectory $neutralName) -Value $neutralText -Encoding UTF8 + $requestCase = [pscustomobject]@{ id = $modelId; file = $neutralName } + if ($case.PSObject.Properties.Name -contains 'context') { + $requestCase | Add-Member -NotePropertyName context -NotePropertyValue ([string]$case.context) + } + $requestCases.Add($requestCase) | Out-Null + $manifestCaseByModelId[$modelId] = $case + $domain = [string]$case.domain + if (-not $requestCasesByDomain.ContainsKey($domain)) { + $requestCasesByDomain[$domain] = [System.Collections.Generic.List[object]]::new() + } + $requestCasesByDomain[$domain].Add($requestCase) | Out-Null + } + $resultSchema = [pscustomobject]@{ + cases = @([pscustomobject]@{ + id = 'case-id' + findings = @([pscustomobject]@{ id = 'repo-relative knowledge article path' }) + }) + } + [pscustomobject]@{ + protocol = 'Run BCQuality al-code-review over all files as one PR; return findings per case. Copy every knowledge-backed id from knowledge-index.json.' + knowledgeIndex = 'knowledge-index.json' + resultSchema = $resultSchema + cases = @($requestCases) + } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $PrepareDirectory 'review-request.json') -Encoding UTF8 + + foreach ($domain in $leafDomains) { + $domainArticles = @($fullIndex.articles | Where-Object domain -eq $domain) + $domainIndexName = "index-$domain.json" + $leafPath = "microsoft/skills/review/al-$domain-review.md" + $leafFullText = Get-Content -LiteralPath (Join-Path $Root $leafPath) -Raw + $leafInstructions = @($leafFullText -split '(?m)^## Output\s*\r?\n', 2)[0] + $leafInstructions += "`n## Output`nReturn only the request's resultSchema." + [pscustomobject]@{ + version = $fullIndex.version + domain = $domain + articleCount = $domainArticles.Count + articles = $domainArticles + } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $PrepareDirectory $domainIndexName) -Encoding UTF8 + + [pscustomobject]@{ + protocol = "Run only $leafPath over these files. Follow leafInstructions exactly, use only the supplied candidate article rows, open matching articles in full, and copy every finding id verbatim from candidateArticles[].path." + skill = $leafPath + leafInstructions = $leafInstructions + knowledgeIndex = $domainIndexName + candidateArticles = $domainArticles + resultSchema = $resultSchema + cases = @($requestCasesByDomain[$domain]) + } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $PrepareDirectory "request-$domain.json") -Encoding UTF8 + + foreach ($requestCase in @($requestCasesByDomain[$domain])) { + $caseText = Get-Content -LiteralPath (Join-Path $PrepareDirectory ([string]$requestCase.file)) -Raw + if ($requestCase.PSObject.Properties.Name -contains 'context') { + $caseText += " $([string]$requestCase.context)" + } + $rankedArticles = @(Get-RankedArticles -Articles $domainArticles -CaseText $caseText) + $manifestCase = $manifestCaseByModelId[[string]$requestCase.id] + $selectedArticlePath = ([string]$manifestCase.input) -replace '\.(?:good|bad)\.al$', '.md' + $rankedPaths = @($rankedArticles | ForEach-Object { [string]$_.path }) + if ($rankedPaths -notcontains $selectedArticlePath) { + throw "$($manifestCase.id): deterministic ranking omitted selected article '$selectedArticlePath'. Improve its retrieval metadata or choose an exceptional override article." + } + # Candidate order must not reveal which article owns the fixture. + $rankedArticles = @($rankedArticles | Sort-Object path) + [pscustomobject]@{ + protocol = "Run only $leafPath over this case. Follow leafInstructions exactly, evaluate the ranked candidate article rows, open matching articles in full, and copy every finding id verbatim from candidateArticles[].path." + skill = $leafPath + leafInstructions = $leafInstructions + knowledgeIndex = $domainIndexName + candidateArticles = $rankedArticles + resultSchema = $resultSchema + cases = @($requestCase) + } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $PrepareDirectory "request-$($requestCase.id).json") -Encoding UTF8 + } + } + Write-Host "Prepared $($cases.Count) neutral fixture(s) in $PrepareDirectory." -ForegroundColor Green +} + +if (-not $ResultsPath -and -not $ResultsDirectory) { + Write-Host "Review fixture validation PASSED: $($cases.Count) cases cover $($leafDomains.Count) leaf domains." -ForegroundColor Green + exit 0 +} + +$resultCases = [System.Collections.Generic.List[object]]::new() +if ($ResultsDirectory) { + if (-not (Test-Path -LiteralPath $ResultsDirectory -PathType Container)) { + throw "Results directory not found: $ResultsDirectory" + } + $resultFiles = @(Get-ChildItem -LiteralPath $ResultsDirectory -File -Filter 'result-case-*.json') + if (-not $resultFiles.Count) { + $resultFiles = @(Get-ChildItem -LiteralPath $ResultsDirectory -File -Filter 'result-*.json') + } + if (-not $resultFiles.Count) { + throw "No result-case-*.json or result-*.json files found in: $ResultsDirectory" + } + foreach ($resultFile in $resultFiles) { + try { + $resultDocument = Get-Content -LiteralPath $resultFile.FullName -Raw | ConvertFrom-Json + } catch { + $problems.Add("$($resultFile.Name): invalid JSON: $($_.Exception.Message)") | Out-Null + continue + } + if ($resultDocument.PSObject.Properties.Name -notcontains 'cases') { + $problems.Add("$($resultFile.Name): result must contain a 'cases' array.") | Out-Null + continue + } + foreach ($resultCase in @($resultDocument.cases)) { + $resultCases.Add($resultCase) | Out-Null + } + } +} else { + if (-not (Test-Path -LiteralPath $ResultsPath -PathType Leaf)) { + throw "Results file not found: $ResultsPath" + } + $resultDocument = Get-Content -LiteralPath $ResultsPath -Raw | ConvertFrom-Json + foreach ($resultCase in @($resultDocument.cases)) { + $resultCases.Add($resultCase) | Out-Null + } +} + +$resultById = @{} +$modelToManifestId = @{} +foreach ($case in $cases) { + $manifestId = [string]$case.id + $modelToManifestId[(Get-ModelCaseId -ManifestId $manifestId)] = $manifestId + # Also accept manifest IDs for maintainers generating local oracle results. + $modelToManifestId[$manifestId] = $manifestId +} +foreach ($resultCase in @($resultCases)) { + $rawResultId = [string]$resultCase.id + if (-not $modelToManifestId.ContainsKey($rawResultId)) { + $problems.Add("Results contain unknown case id: $rawResultId") | Out-Null + continue + } + $resultId = $modelToManifestId[$rawResultId] + if ($resultById.ContainsKey($resultId)) { + $problems.Add("Results contain duplicate case id: $rawResultId") | Out-Null + } else { + $resultById[$resultId] = $resultCase + } +} + +$positiveTotal = 0 +$positivePassed = 0 +$cleanTotal = 0 +$cleanPassed = 0 +foreach ($case in $cases) { + $id = [string]$case.id + if (-not $resultById.ContainsKey($id)) { + $problems.Add("Results missing case: $id") | Out-Null + continue + } + + $findingIds = @( + @($resultById[$id].findings) | ForEach-Object { + if ($_ -is [string]) { [string]$_ } else { [string]$_.id } + } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique + ) + $expected = @($case.expected | ForEach-Object { [string]$_ }) + + if ($expected.Count) { + $positiveTotal++ + $missing = @($expected | Where-Object { $findingIds -notcontains $_ }) + $unexpected = @($findingIds | Where-Object { $expected -notcontains $_ }) + if (-not $missing.Count -and -not $unexpected.Count) { + $positivePassed++ + } else { + if ($missing.Count) { $problems.Add("${id}: missing expected finding(s): $($missing -join ', ')") | Out-Null } + if ($unexpected.Count) { $problems.Add("${id}: unexpected finding(s): $($unexpected -join ', ')") | Out-Null } + } + } else { + $cleanTotal++ + if (-not $findingIds.Count) { + $cleanPassed++ + } else { + $problems.Add("${id}: clean control produced finding(s): $($findingIds -join ', ')") | Out-Null + } + } +} + +$recall = if ($positiveTotal) { $positivePassed / $positiveTotal } else { 0 } +$cleanRate = if ($cleanTotal) { $cleanPassed / $cleanTotal } else { 0 } +if ($recall -lt [double]$manifest.minimumExpectedRecall) { + $problems.Add("Expected-finding recall $recall is below $($manifest.minimumExpectedRecall).") | Out-Null +} +if ($cleanRate -lt [double]$manifest.minimumCleanRate) { + $problems.Add("Clean-control rate $cleanRate is below $($manifest.minimumCleanRate).") | Out-Null +} + +if ($problems.Count) { + Write-Host "Review evaluation FAILED ($($problems.Count) problem(s)):" -ForegroundColor Red + $problems | ForEach-Object { Write-Host " - $_" -ForegroundColor Red } + exit 1 +} + +Write-Host "Review evaluation PASSED: recall=$recall ($positivePassed/$positiveTotal), clean-rate=$cleanRate ($cleanPassed/$cleanTotal)." -ForegroundColor Green +exit 0 From cdd3d99cf83f956cded040259bc0e7d3668197b2 Mon Sep 17 00:00:00 2001 From: wenjiefan Date: Wed, 15 Jul 2026 14:57:17 +0200 Subject: [PATCH 28/86] review: stop emitting reference-less agent findings in privacy and UI/accessibility leaves Online eval (182 PRs) shows the reference-less agent-finding channel is where 86% of false positives come from, and it is net-negative in the lowest-yield domains: privacy agent findings score 0 TP / 8 FP and UI/accessibility 1 TP / 11 FP. Restrict these two leaves to knowledge-backed findings only; an uncovered concern is omitted (and, if material and recurring, fixed durably by adding a BCQuality article per the self-improvement loop) instead of emitted with references: []. --- microsoft/skills/review/al-privacy-review.md | 2 +- microsoft/skills/review/al-ui-review.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index edbf197..ea95269 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -68,7 +68,7 @@ Set `confidence` to: - `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. - `low` when the finding is an advisory derived only from applicability. -After evaluating each worklist entry, also consider whether the diff exhibits a privacy defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material privacy defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly privacy; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. +This leaf emits only knowledge-backed privacy findings. Do NOT emit reference-less `agent:` findings in this domain: online evaluation shows the privacy agent-finding channel yields almost no accepted findings and a high volume of dismissed noise, so a privacy concern that no worklist knowledge file covers is omitted here rather than emitted with `references: []`. When you spot a material privacy defect no article covers, the durable fix is to add a knowledge article in BCQuality (per the online-eval self-improvement loop) so this leaf can cite it — not a one-off reference-less finding. Before treating a candidate as uncovered, check the worklist for a knowledge file that matches it; if one exists, emit it as a knowledge-backed finding. See `skills/do.md` for the full contract. For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index 19f03c9..81ab12e 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -61,7 +61,7 @@ Set `confidence` to: - `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. -After evaluating each worklist entry, also consider whether the diff exhibits a UI defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material UI defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly UI; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. +This leaf emits only knowledge-backed UI and accessibility findings. Do NOT emit reference-less `agent:` findings in this domain: online evaluation shows the UI/accessibility agent-finding channel yields almost no accepted findings and a high volume of dismissed noise, so a UI or accessibility concern that no worklist knowledge file covers is omitted here rather than emitted with `references: []`. When you spot a material UI or accessibility defect no article covers, the durable fix is to add a knowledge article in BCQuality (per the online-eval self-improvement loop) so this leaf can cite it — not a one-off reference-less finding. Before treating a candidate as uncovered, check the worklist for a knowledge file that matches it; if one exists, emit it as a knowledge-backed finding. See `skills/do.md` for the full contract. For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. From b9c57ba6f993feeac860c24808865045be926167 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Fri, 17 Jul 2026 12:15:22 +0200 Subject: [PATCH 29/86] Document the skill-vs-knowledge boundary so BC facts land in knowledge files (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewing #112 surfaced that the authoring docs never state where a new BC fact (or a false-positive guard) belongs, so an agent iterated do.md -> leaf skills -> knowledge files across two review rounds before landing knowledge in a knowledge article. The information to decide existed but was split across README/do.md/write.md and framed only as positive best practices. - do.md: add "Skills hold mechanics; knowledge files hold BC facts" — a skill is a finder/applier; every BC behavioural claim it acts on must be a cited knowledge file. Names negative knowledge (false-positive guards) as first class, and forbids both adding a BC fact to a skill and restating an article's fact inline (the drift/duplication smell). - write.md: add "Is this a knowledge file?" decision gate at the top, plus a "Negative knowledge is first-class" section with the Description/Best Practice/Anti Pattern mapping and a worked example. - README: note that false-positive-preventing files are first-class knowledge and add a reviewer heuristic to Contributing. Prose-only additions; validator passes. Meta-skill contract semantics are unchanged, so version stays 1 (maintainers may bump if they consider the explicit boundary rule a contract change). Copilot-Session: 76eba53e-18cd-4618-a205-3607f260f9f4 Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 3 +++ skills/do.md | 13 +++++++++++++ skills/write.md | 19 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/README.md b/README.md index 3b09ff7..b939c6f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Poor fit: "Use HTTPS instead of HTTP." "Don't hardcode secrets." "Keep transacti 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. +A file that *prevents* a false positive — documenting why a pattern is legitimate so the agent stops flagging it — is as valid as one that catches a defect: negative clarifications are first-class knowledge files. What never belongs is a BC fact hard-coded into a skill. Skills are finders and appliers; knowledge files are what the agent knows. See [`skills/do.md`](skills/do.md) and [`skills/write.md`](skills/write.md). + ## 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. @@ -147,6 +149,7 @@ Contributions are welcome. Before submitting a PR: 1. Read the knowledge file format above — frontmatter and sections are validated by CI. 2. Keep files atomic: one concern per file, under 100 lines. 3. Target your contribution to the right layer — most community contributions go in `/community/knowledge/`. +4. Adding a BC fact — or stopping the agent from flagging a false positive — is a knowledge file, not a skill edit. If a PR changes *what* a review skill flags, the change almost certainly belongs in a knowledge file. See [`skills/write.md`](skills/write.md). CI runs validation on every PR. If your knowledge file has schema violations, missing sections, code blocks, or exceeds 100 lines, the check will fail with a clear error message. diff --git a/skills/do.md b/skills/do.md index 9de11a4..23bfb4a 100644 --- a/skills/do.md +++ b/skills/do.md @@ -21,6 +21,19 @@ An action skill is a single markdown file with YAML frontmatter. It lives inside Action skills do not live at the repo root. The files in `/skills/` — the three meta-skill contracts (READ, DO, WRITE) and the entry-point skill (`entry.md`, `kind: entry-point`) — are the only skills that sit outside a layer. The entry-point skill structurally follows this same four-step pattern but produces a dispatch record rather than a findings-report; see `skills/entry.md` for its contract. +## Skills hold mechanics; knowledge files hold BC facts + +An action skill is a *finder and applier*: its prose says how to discover candidate knowledge (Source), filter it (Relevance), narrow it to the task (Worklist), and shape output (Action). Every Business-Central-specific behavioural claim a skill acts on — what a property defaults to, what a trigger does, why a given shape is or is not a defect — belongs in a knowledge file the skill cites, not in the skill prose. + +This includes **negative knowledge**. A false-positive guard — "pattern X is not a defect, because BC does Y" — is as much a knowledge file as a positive best practice. When an eval shows the agent over-reporting a pattern, the fix is a knowledge file documenting why the pattern is legitimate, so the skill can cite it and any leaf can reuse it — not a hard-coded exclusion buried in one skill. See `skills/write.md` (*Is this a knowledge file?*). + +Two rules follow for skill authors: + +- **Do not add a BC fact to a skill.** If you are editing a skill to change *what it flags* — adding an exclusion, encoding a platform default, teaching it that some pattern is fine — you are holding a knowledge file, not a skill edit. Author the knowledge file and let Worklist route to it. +- **Do not restate an article's fact inline.** A Worklist cue may name the article to load and the diff shape that selects it; it must not re-assert the article's reasoning, which then drifts from the source. Cite, don't copy. + +The meta-skills themselves (`read.md`, `do.md`, `write.md`) are domain-agnostic templates and carry no BC-specific rule. + ## Frontmatter schema ```yaml diff --git a/skills/write.md b/skills/write.md index a9e3c31..731f469 100644 --- a/skills/write.md +++ b/skills/write.md @@ -9,6 +9,25 @@ title: New Knowledge — how to author a knowledge file Anyone — human or agent — adding a knowledge file to BCQuality follows this guide. READ is the format specification; WRITE is the authoring guide. This file does not restate the schema; consult READ for field-by-field semantics. +## Is this a knowledge file? + +Before authoring anything, confirm a knowledge file is the right artifact. BCQuality separates *mechanics* from *facts*: + +- **Skills** (`*/skills/**`) hold only finder/applier mechanics — how to discover, filter, worklist, and emit findings. See `skills/do.md`. +- **Knowledge files** (`*/knowledge/**`) hold every Business-Central-specific fact a skill acts on. + +A new BC fact is therefore a knowledge file, never a skill edit. In particular, if you arrived here because a review agent flagged something it should not have (a false positive) or missed something it should have caught, the remedy is a knowledge file — apply the admission test in the [README](../README.md#what-belongs-here): *would a capable LLM get this wrong without the file?* If you find yourself editing a skill to stop it flagging something, stop and write a knowledge file instead. + +### Negative knowledge is first-class + +A knowledge file does not have to recommend an action. A **negative clarification** — "pattern X is *not* a defect, because BC behaves as Y" — is a first-class knowledge file, authored exactly like a positive rule: + +- **Description** states the BC behaviour that makes the pattern legitimate. +- **Best Practice** tells the reviewer or agent what *not* to flag, and why. +- **Anti Pattern** describes the false-positive report itself — the mistaken finding to suppress. + +For example, `microsoft/knowledge/error-handling/page-boolean-triggers-default-to-true.md` records that the Boolean page record triggers return `true` by default, so a "missing `exit(true)`" report is not a real defect. It reads as ordinary knowledge; its anti-pattern is the incorrect review comment, not the code. + ## Before you start Read `skills/read.md` first. A file that does not conform to READ will be rejected. WRITE assumes READ is already understood. From 1bf5a3b2764111945051c3881dd0ec338484b3a5 Mon Sep 17 00:00:00 2001 From: Wenjie Fan <31087545+gggdttt@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:17:04 +0200 Subject: [PATCH 30/86] Add precision guards for systematic agent false-positive patterns (#112) * Add precision guards for systematic agent false-positive patterns Encodes reviewer-confirmed FP guards from the online eval: tooltip-inherited, page-trigger default return, drill-down filter not visible in diff, dual-trigger CalcFields (do.md); and a released-baseline precondition for breaking-change/upgrade findings on never-shipped symbols. * Scope suggested-code and location to exactly the changed lines Addresses reviewer-reported misplaced suggestions from the eval: insert-only-property emitting the whole field, single-statement rewrites anchored on the procedure name, and reductive multi-line collapses. The skill now emits a location range that matches precisely the rewritten lines. * Correct suggested-code scoping guidance to match one-click anchor mechanics A lone inserted line matches no existing file line and cannot be anchored; bracket the new line with one adjacent unchanged line instead. Reductive collapses omit suggested-code and fall back to a manual snippet. * Move BC-specific FP guards out of do.md into leaf skills do.md is the stable action-skill template and must stay domain-agnostic (JesperSchulz review). Relocate the four known false-positive patterns to their domain leaves: ToolTip-inheritance to al-ui-review; drill-down/lookup filtering and CalcFields lifecycle to al-performance-review; page-trigger exit(true) semantics to al-error-handling-review. * Move suggested-code line-scoping guidance out of do.md into al-code-review do.md must not carry instructions for how the review skill behaves (JesperSchulz review). Relocate the location/suggested-code precise-span rules to al-code-review's existing Suggested-code guidance section. do.md is now unchanged vs main. * Move false-positive guards from skills into knowledge files Keep review skills slim (finders/appliers). The FP guards and released-baseline preconditions previously embedded in leaf skills become negative-clarification knowledge articles in their domains, and the agent-findings policy edits to al-ui/al-privacy are reverted to main. Adds 6 knowledge files: error-handling (page-boolean-triggers-default-to-true), ui (bound-page-field-inherits-source-field-tooltip), performance (calcfields-in-both-getrecord-triggers-is-not-redundant, page-effective-filter-may-live-outside-the-diff), breaking-changes (unreleased-symbol-change-is-not-a-breaking-change), upgrade (unreleased-schema-change-needs-no-upgrade-path). * Revert branch's suggested-code scoping addition in al-code-review The three location-span shapes added to al-code-review are output-format mechanics, not domain knowledge: one-click span correctness is the engine's job (Resolve-SuggestionPlacement) and do.md already owns the suggested-code/location contract. The AL concerns the examples illustrate are already covered by existing knowledge (use-isempty-for-existence-check, data-classification-required-on-pii-fields, no-space-before-method-parenthesis). Restores al-code-review to main; the branch now adds only the 6 knowledge files. * Restore al-ui/al-privacy review skills to base (zero diff in PR) These two leaf skills carried an accidental net change against the PR merge-base because an earlier revert used the current origin/main (post-#110) instead of the branch base (pre-#110). Restoring them to the merge-base version removes them from the PR diff entirely. Three-way merge still preserves main's #110 suppression. --------- Co-authored-by: wenjiefan --- ...-symbol-change-is-not-a-breaking-change.md | 24 +++++++++++++++++++ .../page-boolean-triggers-default-to-true.md | 24 +++++++++++++++++++ ...oth-getrecord-triggers-is-not-redundant.md | 22 +++++++++++++++++ ...ective-filter-may-live-outside-the-diff.md | 22 +++++++++++++++++ ...age-field-inherits-source-field-tooltip.md | 24 +++++++++++++++++++ ...sed-schema-change-needs-no-upgrade-path.md | 24 +++++++++++++++++++ 6 files changed, 140 insertions(+) create mode 100644 microsoft/knowledge/breaking-changes/unreleased-symbol-change-is-not-a-breaking-change.md create mode 100644 microsoft/knowledge/error-handling/page-boolean-triggers-default-to-true.md create mode 100644 microsoft/knowledge/performance/calcfields-in-both-getrecord-triggers-is-not-redundant.md create mode 100644 microsoft/knowledge/performance/page-effective-filter-may-live-outside-the-diff.md create mode 100644 microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md create mode 100644 microsoft/knowledge/upgrade/unreleased-schema-change-needs-no-upgrade-path.md diff --git a/microsoft/knowledge/breaking-changes/unreleased-symbol-change-is-not-a-breaking-change.md b/microsoft/knowledge/breaking-changes/unreleased-symbol-change-is-not-a-breaking-change.md new file mode 100644 index 0000000..fc39c13 --- /dev/null +++ b/microsoft/knowledge/breaking-changes/unreleased-symbol-change-is-not-a-breaking-change.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: breaking-changes +keywords: [released-baseline, unreleased, rename, renumber, obsolete, api-stability, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Changing an unreleased symbol is not a breaking change + +## Description + +Breaking-change rules protect contracts that have already shipped to customers or are exposed to external extensions. A symbol — an object, field, key, enum value, or procedure — that is new in this app, was introduced and then changed within the same still-unreleased development cycle, or belongs to an app that has no released version yet, can be renamed, renumbered, or removed freely. There is no shipped contract to break, so the change is not a breaking change. + +Release status is established from the diff, the app's `app.json` version, or a released baseline. An app whose `app.json` version has no corresponding released baseline (for example a `1.0.0.0` app that has never shipped) has no protected surface. + +## Best Practice + +Before treating a rename, renumber, or removal as breaking, establish that the affected symbol was present in a released baseline. Do not flag changes to symbols that are new in the current unreleased cycle or that belong to an app with no released version. When release status cannot be established from the diff, `app.json`, or a released baseline, omit the finding rather than assert a break. + +## Anti Pattern + +Reporting a breaking change for a rename, renumber, or removal without confirming the symbol shipped in a released version — for example flagging a break on an app whose `app.json` version has no released baseline. diff --git a/microsoft/knowledge/error-handling/page-boolean-triggers-default-to-true.md b/microsoft/knowledge/error-handling/page-boolean-triggers-default-to-true.md new file mode 100644 index 0000000..f637fae --- /dev/null +++ b/microsoft/knowledge/error-handling/page-boolean-triggers-default-to-true.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: error-handling +keywords: [oninsertrecord, onmodifyrecord, ondeleterecord, onquerypage, boolean-trigger, exit, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Page record triggers return true by default; a missing exit(true) does not block the operation + +## Description + +The Boolean page record triggers `OnInsertRecord`, `OnModifyRecord`, `OnDeleteRecord`, and `OnQueryClosePage` return `true` by default. When the trigger body omits an explicit return value, the platform treats the result as `true` and the operation proceeds. Only an explicit `exit(false)` — or a reachable code path that returns `false` — cancels the insert, modify, delete, or page close. + +This is a defined exception to the ordinary Boolean method rule, where the default return is `false`. Reviewers unfamiliar with the exception sometimes read a page record trigger that has no `exit(true)` and conclude the operation is blocked; it is not. + +## Best Practice + +Do not claim that a missing `exit(true)` blocks or prevents an insert, modify, or delete, and do not recommend adding `exit(true)` "to let the operation proceed" — that is already the default. Evaluate these triggers only for an explicit or reachable `exit(false)`/false-returning path that would cancel the operation unintentionally. + +## Anti Pattern + +Flagging `OnInsertRecord`, `OnModifyRecord`, `OnDeleteRecord`, or `OnQueryClosePage` as defective because it "does not return `true`", or asserting that inserts/modifies/deletes will silently fail without an explicit `exit(true)`. The default return already permits the operation. diff --git a/microsoft/knowledge/performance/calcfields-in-both-getrecord-triggers-is-not-redundant.md b/microsoft/knowledge/performance/calcfields-in-both-getrecord-triggers-is-not-redundant.md new file mode 100644 index 0000000..79c3b52 --- /dev/null +++ b/microsoft/knowledge/performance/calcfields-in-both-getrecord-triggers-is-not-redundant.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [calcfields, onaftergetrecord, onaftergetcurrrecord, page-lifecycle, flowfield, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# CalcFields in both OnAfterGetRecord and OnAfterGetCurrRecord is not redundant + +## Description + +`OnAfterGetRecord` fires once per row as the page loads records into the view; `OnAfterGetCurrRecord` fires when a record becomes the active/current record. Calling `CalcFields` in both triggers is not duplicate or redundant work: the two triggers run at different points in the page lifecycle and serve different purposes — populating FlowFields for every displayed row versus refreshing them for the record the user has selected. The same `CalcFields` call appearing in both places is an intentional pattern, not copy-paste waste. + +## Best Practice + +Do not flag `CalcFields` appearing in both `OnAfterGetRecord` and `OnAfterGetCurrRecord` as duplicate, redundant, or removable. Treat each trigger's `CalcFields` on its own lifecycle merits. + +## Anti Pattern + +Recommending that a developer delete one of the two `CalcFields` calls because "the field is already calculated in the other trigger". The genuine per-row FlowField cost is addressed by the separate guidance on FlowField calculation in loops and on hidden FlowFields; it is not addressed by removing a lifecycle-correct `CalcFields`. diff --git a/microsoft/knowledge/performance/page-effective-filter-may-live-outside-the-diff.md b/microsoft/knowledge/performance/page-effective-filter-may-live-outside-the-diff.md new file mode 100644 index 0000000..50eb014 --- /dev/null +++ b/microsoft/knowledge/performance/page-effective-filter-may-live-outside-the-diff.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [filter, drilldown, lookup, sourcetableview, tablerelation, setrange, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A page or lookup's effective filter may be defined outside the changed hunk + +## Description + +The effective filter on a drill-down, lookup, or list result set is frequently defined outside any single changed hunk — on the table via a `SourceTableView` property or a `TableRelation`, or through `SetRange`/`SetFilter` calls in unchanged code that runs before the result is shown. The absence of a filter within the changed lines of a diff is therefore not evidence that the result set is unfiltered or that it will load an entire table. + +## Best Practice + +Do not assert that a drill-down, lookup, or list is "unfiltered" based only on the changed hunk. Confirm the effective filter by checking the page's `SourceTableView`, the field's `TableRelation`, and any `SetRange`/`SetFilter` in the surrounding (possibly unchanged) code before raising a finding about an unbounded result set. + +## Anti Pattern + +Concluding that a lookup or drill-down loads an unfiltered, full-table result set solely because no `SetRange`/`SetFilter` appears in the changed lines, when the filter is defined on the table, in a `TableRelation`, or in unchanged setup code. diff --git a/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md b/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md new file mode 100644 index 0000000..a258506 --- /dev/null +++ b/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: ui +keywords: [tooltip, page-field, source-field, inheritance, aa0218, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A page field bound to a table field inherits that field's ToolTip + +## Description + +A page field bound to a table field inherits the source field's `ToolTip` at runtime: the control shows the table field's `ToolTip` even when the page control declares none of its own. A page field without an inline `ToolTip` is therefore not, by itself, a missing-tooltip defect — the text may be supplied by the bound source field. + +The genuinely-missing case — a bound field whose source table field also carries no `ToolTip`, or an unbound control that needs one — is already reported by the compiler analyzer AA0218, which BCQuality calibrates to `info`. That analyzer, not an agent finding, owns the missing-tooltip signal. + +## Best Practice + +Do not raise a missing-`ToolTip` finding for a page field that has a source-table binding; assume the source field supplies the tooltip. Reserve tooltip findings for the cases the dedicated tooltip rules define, and let analyzer AA0218 carry the mechanically-detectable missing-tooltip case at its calibrated severity. + +## Anti Pattern + +Flagging every page field that has no inline `ToolTip` property as an accessibility violation, ignoring that a bound field inherits its source field's tooltip and that AA0218 already covers the truly-missing case. diff --git a/microsoft/knowledge/upgrade/unreleased-schema-change-needs-no-upgrade-path.md b/microsoft/knowledge/upgrade/unreleased-schema-change-needs-no-upgrade-path.md new file mode 100644 index 0000000..248943f --- /dev/null +++ b/microsoft/knowledge/upgrade/unreleased-schema-change-needs-no-upgrade-path.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [released-baseline, unreleased, schema, migration, obsolete, data-loss, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Unreleased schema changes need no upgrade or migration path + +## Description + +Upgrade and migration findings protect data and schema that have already shipped to customers. A schema element — a table, field, key, or enum — that is new in this app, or was added and then changed within the same still-unreleased development cycle, needs no upgrade code or migration path: no customer has data in it yet, so there is nothing to preserve or migrate. Such a change is not an obsoletion, data-loss, or breaking-migration defect. + +Release status is established from the diff, the app's `app.json` version, or a released baseline. A schema element with no released baseline has no persisted customer data to protect. + +## Best Practice + +Before asserting an obsoletion, data-loss, or breaking-migration defect, establish that the affected table, field, key, or enum existed in a released version. Do not require upgrade or migration code for schema that never shipped. When release status cannot be established from the diff, `app.json`, or a released baseline, omit the finding rather than demand a migration path. + +## Anti Pattern + +Demanding an upgrade codeunit, migration path, or data-preservation step, or flagging data loss, for a table, field, key, or enum that is new in the current unreleased cycle and has no released baseline. From 712dee9ec1f0b098f859753e083e8c6efa903d01 Mon Sep 17 00:00:00 2001 From: Wenjie Fan <31087545+gggdttt@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:17:32 +0200 Subject: [PATCH 31/86] style-review: calibrate variable-declaration order (AA0021) to info (#109) Online eval shows style/variable-declaration-order-by-type firing as false positives (tp/fp 2/1) at minor. The article title itself is 'Order variable declarations by type (CodeCop AA0021)', so it is analyzer-redundant exactly like the this-keyword AA0248, label-suffix AA0074, and ToolTip rules already calibrated to info. Add AA0021 to the analyzer-redundant list so it emits at info and stops competing with substantive style review. Co-authored-by: wenjiefan --- microsoft/skills/review/al-style-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index c570a08..ee4daf5 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -60,7 +60,7 @@ When the post-conflict worklist is empty because no applicable style knowledge e 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`. -Severity calibration — a formal analyzer already flags the mechanical presence/naming conventions (the `this` keyword AA0248, approved label suffixes AA0074, a missing `ToolTip`, required parentheses). On those, BCQuality's value is the *explanation* of why the rule exists, not a second gate; emit them at `info` so a consumer that gates on severity does not re-flag what CodeCop/AppSourceCop already reports. Reserve `minor` for style issues with concrete downstream impact the analyzer does not catch — a `Label` declared at procedure-local instead of object scope (no analyzer enforces label scope, and mis-scoped Labels are fragile in the translation pipeline), lost translation or telemetry classification from a string-built `Error`, an `OptionCaption` that does not match its `OptionMembers`, a misleading named invocation. This keeps the domain's default output advisory and prevents analyzer-redundant noise from competing with substantive review. +Severity calibration — a formal analyzer already flags the mechanical presence/naming conventions (the `this` keyword AA0248, approved label suffixes AA0074, variable-declaration order by type AA0021, a missing `ToolTip`, required parentheses). On those, BCQuality's value is the *explanation* of why the rule exists, not a second gate; emit them at `info` so a consumer that gates on severity does not re-flag what CodeCop/AppSourceCop already reports. Reserve `minor` for style issues with concrete downstream impact the analyzer does not catch — a `Label` declared at procedure-local instead of object scope (no analyzer enforces label scope, and mis-scoped Labels are fragile in the translation pipeline), lost translation or telemetry classification from a string-built `Error`, an `OptionCaption` that does not match its `OptionMembers`, a misleading named invocation. This keeps the domain's default output advisory and prevents analyzer-redundant noise from competing with substantive review. Set `confidence` to: From 89bf8fde311215379faca7f75f8b8c2998e4ceac Mon Sep 17 00:00:00 2001 From: wenjiefan Date: Mon, 20 Jul 2026 14:30:29 +0200 Subject: [PATCH 32/86] tooltip: PR review flags genuinely-missing tooltips instead of deferring to AA0218 The v1.4 policy deferred every missing-tooltip case to compiler analyzer AA0218. But AA0218 severity is per-app ruleset config and is routinely downgraded to info/None or disabled, so a genuine gap can ship unflagged. PR review is the last line of defence and should raise it independently. Keeps the false-positive guard intact: a bound field whose source table field supplies a ToolTip still inherits it and is NOT flagged. Adds the genuinely-missing case (bound field whose source is also tooltip-less, or an unbound control) as a medium-severity finding. Updates both the ui inheritance article and the style AA0218 article. --- .../knowledge/style/tooltip-required-on-page-fields.md | 4 +++- .../ui/bound-page-field-inherits-source-field-tooltip.md | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.md b/microsoft/knowledge/style/tooltip-required-on-page-fields.md index fc5a3cb..a11d4a9 100644 --- a/microsoft/knowledge/style/tooltip-required-on-page-fields.md +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.md @@ -15,9 +15,11 @@ CodeCop AA0218 requires a non-empty `ToolTip` property on every field control on 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. +AA0218 is a compiler analyzer, but its severity is configured per app in the ruleset and is frequently downgraded to `info`/`None` or disabled entirely. PR review therefore cannot assume the compiler will surface the gap: it is the last line of defence for a missing tooltip and should flag it independently. The one case review must *not* flag is a bound field that inherits a `ToolTip` from its source table field — see `bound-page-field-inherits-source-field-tooltip`. + ## 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". +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". In review, raise a `medium`-severity finding for a field that has neither an inline nor an inherited tooltip, independently of whether AA0218 is active in the app's ruleset. See sample: `tooltip-required-on-page-fields.good.al`. diff --git a/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md b/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md index a258506..87b539b 100644 --- a/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md +++ b/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md @@ -13,12 +13,12 @@ application-area: [all] A page field bound to a table field inherits the source field's `ToolTip` at runtime: the control shows the table field's `ToolTip` even when the page control declares none of its own. A page field without an inline `ToolTip` is therefore not, by itself, a missing-tooltip defect — the text may be supplied by the bound source field. -The genuinely-missing case — a bound field whose source table field also carries no `ToolTip`, or an unbound control that needs one — is already reported by the compiler analyzer AA0218, which BCQuality calibrates to `info`. That analyzer, not an agent finding, owns the missing-tooltip signal. +The genuinely-missing case is different: a bound field whose source table field *also* carries no `ToolTip`, or an unbound control, has no text to inherit and is a real accessibility gap. The compiler analyzer AA0218 detects this mechanically, but its severity is set by each app's ruleset and is routinely downgraded or disabled — so it cannot be relied on as the only net. PR review is the last line of defence and should raise this case independently. ## Best Practice -Do not raise a missing-`ToolTip` finding for a page field that has a source-table binding; assume the source field supplies the tooltip. Reserve tooltip findings for the cases the dedicated tooltip rules define, and let analyzer AA0218 carry the mechanically-detectable missing-tooltip case at its calibrated severity. +Do not raise a missing-`ToolTip` finding for a bound page field whose source table field supplies a `ToolTip`; assume the control inherits it. Do raise a `medium`-severity finding when the field has no inline `ToolTip` **and** no inherited one — that is, a bound field whose source field is also tooltip-less, or an unbound control — rather than assuming AA0218 will catch it downstream. ## Anti Pattern -Flagging every page field that has no inline `ToolTip` property as an accessibility violation, ignoring that a bound field inherits its source field's tooltip and that AA0218 already covers the truly-missing case. +Two opposite failures: (1) flagging every page field that has no inline `ToolTip` as a violation, ignoring that a bound field inherits its source field's tooltip; and (2) staying silent on a field that has neither an inline nor an inherited tooltip on the assumption that the compiler's AA0218 will report it — a ruleset that downgrades or disables AA0218 then lets a genuine gap ship unflagged. From c618071ea66c1c5d661b18ece994e8b6bb53be03 Mon Sep 17 00:00:00 2001 From: wenjiefan Date: Tue, 21 Jul 2026 09:45:09 +0200 Subject: [PATCH 33/86] knowledge: add 3 FP-suppression guards from BC apps negative feedback - upgrade/obsoletereason-need-not-restate-removal-version: ObsoleteTag carries the version; do not flag ObsoleteReason for omitting it (PR 8290) - error-handling/unchecked-get-throws-when-record-not-found: a bare Rec.Get() errors on missing record; it is not silently ignored (PR 8584) - performance/onaftergetcurrrecord-is-not-per-row: OnAfterGetCurrRecord fires on selection change, not per row; CalcFields there is not N+1 (PR 8617) --- ...hecked-get-throws-when-record-not-found.md | 26 +++++++++++++++++++ .../onaftergetcurrrecord-is-not-per-row.md | 26 +++++++++++++++++++ ...reason-need-not-restate-removal-version.md | 26 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 microsoft/knowledge/error-handling/unchecked-get-throws-when-record-not-found.md create mode 100644 microsoft/knowledge/performance/onaftergetcurrrecord-is-not-per-row.md create mode 100644 microsoft/knowledge/upgrade/obsoletereason-need-not-restate-removal-version.md diff --git a/microsoft/knowledge/error-handling/unchecked-get-throws-when-record-not-found.md b/microsoft/knowledge/error-handling/unchecked-get-throws-when-record-not-found.md new file mode 100644 index 0000000..dff1e81 --- /dev/null +++ b/microsoft/knowledge/error-handling/unchecked-get-throws-when-record-not-found.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: error-handling +keywords: [get, record-not-found, runtime-error, return-value, boolean-method, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# An unchecked Record.Get raises an error when the record is missing; it is not silently ignored + +## Description + +`Record.Get` returns a Boolean, but its behavior when no record is found depends on whether the return value is consumed. When the return value is used — inside `if Rec.Get(...) then`, or assigned to a variable — a missing record yields `false` and execution continues. When `Rec.Get(...)` is called as a bare statement and the return value is not used, the platform raises a runtime "record not found" error if the record does not exist. A bare `Rec.Get(Key)` therefore acts as an assertion that the record exists: it does not swallow or silently ignore a missing record. This mirrors other AL find methods, where an unconsumed return value lets the platform enforce the not-found error. + +## Best Practice + +Do not claim that a `Record.Get` whose return value is unused silently ignores a missing record or hides an error. Treat a bare `Rec.Get(...)` statement as an intentional existence assertion that already throws when the record is absent. Recommend an explicit existence check only when the surrounding logic must continue gracefully rather than error out. + +## Anti Pattern + +Flagging a bare `Rec.Get(Key)` statement as a defect because "the return value is ignored, so a missing record is swallowed", or recommending it be wrapped in `if Rec.Get(...) then ... else Error(...)` to "handle the not-found case" — the unchecked call already raises an error when the record is missing. + +## See also + +- `ignored-tryfunction-return-disables-try-semantics.md` — a different case where ignoring a Boolean return value changes behavior. diff --git a/microsoft/knowledge/performance/onaftergetcurrrecord-is-not-per-row.md b/microsoft/knowledge/performance/onaftergetcurrrecord-is-not-per-row.md new file mode 100644 index 0000000..928ad02 --- /dev/null +++ b/microsoft/knowledge/performance/onaftergetcurrrecord-is-not-per-row.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [onaftergetcurrrecord, onaftergetrecord, calcfields, n-plus-one, page-lifecycle, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Database work in OnAfterGetCurrRecord is not a per-row or N+1 cost + +## Description + +`OnAfterGetCurrRecord` fires only when the current/active record changes — typically once when the page opens and once each time the user selects a different row — not once for every row rendered in a list. Database work placed there, such as `CalcFields`, `Get`, or a lookup, therefore runs a bounded number of times driven by user navigation, not multiplied by the number of visible rows. This is unlike `OnAfterGetRecord`, which fires once per row as the page loads records and can create a genuine N+1 pattern. Reviewers sometimes see `CalcFields` or a database call inside a page trigger and assume it runs for every row; the trigger name determines whether that assumption holds. + +## Best Practice + +Before flagging `CalcFields`, `Get`, or a similar database call in a page trigger as a per-row or N+1 problem, confirm the trigger is `OnAfterGetRecord`, which runs per row. Do not flag the same work in `OnAfterGetCurrRecord`: that trigger runs on current-record change, not for every displayed row. + +## Anti Pattern + +Reporting `CalcFields` or another database call inside `OnAfterGetCurrRecord` as an N+1 or per-row performance defect, or recommending it be moved out "to avoid running once per row". The trigger does not run per row. + +## See also + +- `calcfields-in-both-getrecord-triggers-is-not-redundant.md` — the lifecycle distinction between the two triggers. diff --git a/microsoft/knowledge/upgrade/obsoletereason-need-not-restate-removal-version.md b/microsoft/knowledge/upgrade/obsoletereason-need-not-restate-removal-version.md new file mode 100644 index 0000000..9165921 --- /dev/null +++ b/microsoft/knowledge/upgrade/obsoletereason-need-not-restate-removal-version.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [obsolete-reason, obsolete-tag, deprecation, version, metadata, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ObsoleteReason need not restate the removal version; ObsoleteTag carries it + +## Description + +An obsoleted object, field, key, enum, or enum value carries both `ObsoleteReason` and `ObsoleteTag`, and the two properties have different jobs. `ObsoleteReason` is free text that explains why the element is obsolete and what replaces it. `ObsoleteTag` identifies when it became obsolete — typically the version, release, or work item that introduced the obsoletion. The version traceability lives in `ObsoleteTag`; there is no requirement that `ObsoleteReason` also name the removal version or repeat what the tag already records. A reason that omits a version number is complete as long as it explains the deprecation and points to a replacement, provided `ObsoleteTag` pins the version. + +## Best Practice + +When `ObsoleteTag` already carries the version or tracking reference, do not flag `ObsoleteReason` for not mentioning a version or removal release. Judge `ObsoleteReason` on whether it explains the deprecation and names a replacement, and judge version traceability on `ObsoleteTag` instead. + +## Anti Pattern + +Flagging an `ObsoleteReason` as vague, incomplete, or missing a version reference solely because it does not restate the removal version, when `ObsoleteTag` already records that version. Requiring the reason to duplicate the tag's version is not a real convention. + +## See also + +- `obsoletion-requires-reason-and-tag.md` — both properties are required; the reason names the replacement and the tag identifies when the element became obsolete. From 2b401aa38ea8be8227542d16e3c54adf3738e6c5 Mon Sep 17 00:00:00 2001 From: wenjiefan Date: Tue, 21 Jul 2026 11:08:04 +0200 Subject: [PATCH 34/86] Add batch-2 FP guards: sentence-case action captions + primary-key Get is transaction-cached --- ...y-key-get-in-loop-is-transaction-cached.md | 24 ++++++++++++ ...ion-noun-phrase-vs-sentence-phrase.good.al | 38 +++++++++++++++++++ ...lization-noun-phrase-vs-sentence-phrase.md | 26 +++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 microsoft/knowledge/performance/primary-key-get-in-loop-is-transaction-cached.md 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 diff --git a/microsoft/knowledge/performance/primary-key-get-in-loop-is-transaction-cached.md b/microsoft/knowledge/performance/primary-key-get-in-loop-is-transaction-cached.md new file mode 100644 index 0000000..ed41f1b --- /dev/null +++ b/microsoft/knowledge/performance/primary-key-get-in-loop-is-transaction-cached.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [get, primary-key, record-cache, transaction, n-plus-one, dictionary-cache, over-engineering, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A primary-key Get() in a per-row helper is not an N+1 to cache manually + +## Description + +The Business Central server caches primary-key reads within a transaction. Repeated `Record.Get()` calls for the same key are served from that cache rather than re-queried, so a guarded `if not Rec.Get(...) then exit;` inside a per-row helper is not a genuine N+1 pattern. When each row legitimately carries a distinct key — for example one `Bin Content` row per bin, so `Bin.Get` and `BinType.Get` see a different bin each iteration — the `Get` must run per row regardless, and there is nothing to hoist. + +Reviewers sometimes see two `Get` calls inside a routine that runs once per row and recommend wrapping them in a `Dictionary` cache. That is over-engineering: it duplicates the server's built-in record cache, adds state that must be invalidated, and breaks the surrounding extension's established pattern of direct guarded `Get` calls. + +## Best Practice + +Treat a primary-key `Get()` — especially a guarded `if not Rec.Get(...) then exit;` — as a cheap, transaction-cached read. Do not recommend a manual `Dictionary` cache around per-row primary-key `Get` calls. Reserve N+1 concerns for genuinely repeated non-keyed queries (`FindSet`/`FindFirst` with filters, `Count`) that re-hit the database each iteration. + +## Anti Pattern + +Reporting repeated primary-key `Get` calls (such as `Bin.Get` and `BinType.Get`) inside a per-row helper as a performance defect, or recommending they be cached in a `Dictionary`. The reads are already cached by the server within the transaction, and per-row keys often differ so the calls cannot be hoisted. 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..077bcdf --- /dev/null +++ b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.good.al @@ -0,0 +1,38 @@ +page 50210 "UI Sample Caption Case" +{ + PageType = List; + ApplicationArea = All; + SourceTable = "Sales Line"; + + layout + { + area(Content) + { + repeater(Lines) + { + field("Document No."; Rec."Document No.") + { + ToolTip = 'Specifies the document number.'; + } + } + } + } + + actions + { + area(Processing) + { + action(ShowSourceDocument) + { + Caption = 'Show source document'; + Image = ViewSourceDocumentLine; + ToolTip = 'Open the related source document.'; + + trigger OnAction() + begin + Message('%1', Rec."Document No."); + end; + } + } + } +} 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..f0d3f8c --- /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, sentence-case, title-case, action, noun-phrase, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Sentence-phrase captions use sentence case, not title case + +## Description + +Business Central caption capitalization depends on whether the caption reads as a **noun phrase** or a **sentence/verb phrase**. Following the Microsoft writing-style guideline, a caption that reads as an imperative sentence — most action captions, such as `'Show source document'`, `'Post and print'`, or `'Copy from last inspection'` — uses **sentence case**: only the first word and any proper nouns are capitalized. Title case (`'Show Source Document'`) is the older convention and is not required for these captions. + +Noun-phrase captions (object names, field labels such as `'Source Document No.'`) follow their own capitalization; that is a separate case and is not what this article covers. Reviewers sometimes see a lower-cased word in an action caption (`'Show source document'`) and flag it as inconsistent title case, but a sentence-phrase action caption is correct as written. + +## Best Practice + +For an action `Caption` that reads as a sentence or verb phrase, capitalize only the first word and proper nouns (sentence case). Do not require every significant word to be capitalized. Before flagging a caption as "should be title case", confirm it is a noun phrase; leave imperative/sentence-phrase action captions in sentence case. + +See sample: `caption-capitalization-noun-phrase-vs-sentence-phrase.good.al`. + +## Anti Pattern + +Reporting a sentence-case action caption such as `'Show source document'` as a style defect and recommending title case (`'Show Source Document'`), or calling it inconsistent with BC conventions. Sentence case is the current guideline for sentence-phrase captions. From 5970984603799dae1bba85b72c1a7b4c2c4e7178 Mon Sep 17 00:00:00 2001 From: wenjiefan Date: Wed, 29 Jul 2026 15:58:02 +0200 Subject: [PATCH 35/86] Move plugin.json to plugin root; declare skills for CLI/marketplace compliance The GitHub Copilot CLI plugin reference requires plugin.json at the root of the plugin directory. BCQuality shipped it only under .claude-plugin/, which is tolerated by --plugin-dir but is non-canonical and can be rejected on the marketplace / 'plugin install owner/repo' path. Mirror the proven microsoft/BC-ALAgents al-review plugin layout: move plugin.json to the repo (plugin) root and add an explicit skills array plus repository/license/keywords metadata to both plugin.json and marketplace.json. The skills array pins the one real skill (skills/bcquality-al-review/), avoiding ambiguity with the loose meta .md files in skills/. Verified: 'copilot --plugin-dir ' still loads the bcquality plugin and the bcquality-al-review skill registers at runtime, identical to before. --- .claude-plugin/marketplace.json | 5 ++++- .claude-plugin/plugin.json => plugin.json | 14 +++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) rename .claude-plugin/plugin.json => plugin.json (59%) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6aa3f8a..1aa47c6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,10 @@ "name": "bcquality", "source": "./", "description": "Business Central AL quality knowledge base and review skills, packaged as an installable plugin. Ships the entire BCQuality tree (skills, knowledge, tools) so the Entry routing protocol runs against the installed clone.", - "version": "0.1.0" + "version": "0.1.0", + "skills": [ + "./skills/bcquality-al-review/" + ] } ] } diff --git a/.claude-plugin/plugin.json b/plugin.json similarity index 59% rename from .claude-plugin/plugin.json rename to plugin.json index bacea2a..0934bd3 100644 --- a/.claude-plugin/plugin.json +++ b/plugin.json @@ -5,5 +5,17 @@ "author": { "name": "microsoft/BCQuality", "url": "https://github.com/microsoft/BCQuality" - } + }, + "repository": "https://github.com/microsoft/BCQuality", + "license": "MIT", + "keywords": [ + "bc", + "al", + "business-central", + "code-review", + "quality" + ], + "skills": [ + "./skills/bcquality-al-review/" + ] } From 88dcfd1a7650ded809e2269c9b6975f4991540e2 Mon Sep 17 00:00:00 2001 From: wenjiefan Date: Thu, 30 Jul 2026 11:13:33 +0200 Subject: [PATCH 36/86] Fix bridge skill manifest references after #122 root move #122 moved the plugin manifest to the root plugin.json (and moved the bridge skill to skills/bcquality-al-review/), but the bridge SKILL.md prose still pointed at the now-deleted .claude-plugin/plugin.json: - ## Plugin root told the host to resolve PLUGIN_ROOT by anchoring on .claude-plugin/plugin.json, a marker that no longer exists, so the location-based fallback could never find it. Anchor on root plugin.json. - ## Notes described .claude-plugin/plugin.json as the manifest the plugin uses and root plugin.json as a future form -- the reverse of reality after #122. Describe root plugin.json as canonical and .claude-plugin/ marketplace.json as the marketplace entry. Doc-only; no behavior change. --- skills/bcquality-al-review/SKILL.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/skills/bcquality-al-review/SKILL.md b/skills/bcquality-al-review/SKILL.md index 20b2417..32c8207 100644 --- a/skills/bcquality-al-review/SKILL.md +++ b/skills/bcquality-al-review/SKILL.md @@ -24,8 +24,8 @@ Do **not** use this skill to *generate* AL code — it only reviews. ## Plugin root -Resolve `PLUGIN_ROOT` to the directory that contains this plugin's -`.claude-plugin/plugin.json`. This skill lives at +Resolve `PLUGIN_ROOT` to the directory that contains this plugin's root +`plugin.json`. This skill lives at `PLUGIN_ROOT/skills/bcquality-al-review/SKILL.md`, so `PLUGIN_ROOT` is two levels up from this file. All paths below are relative to `PLUGIN_ROOT`. If the host exposes a plugin-root environment variable, prefer it. @@ -93,7 +93,8 @@ caller can log the reason. `enabled-layers` (`BCQUALITY_ENABLED_LAYERS`) — the denied layers' files still exist on disk. Treat `enabled-layers` as a selection filter, not a hard security boundary. A future revision could add a genuine deny mechanism (e.g. pruning the installed tree). -- **Manifest location.** This plugin uses `.claude-plugin/plugin.json`, which both +- **Manifest location.** This plugin's manifest is the root `plugin.json`, which both Claude Code and Copilot CLI accept (verified with Copilot CLI: `plugin install` - reports the bridge skill loaded). Copilot CLI also accepts a root `plugin.json`; if a - future host only reads the root form, dual-home the manifest. + reports the bridge skill loaded). A `.claude-plugin/marketplace.json` alongside it + carries the marketplace entry. Claude Code also reads `.claude-plugin/plugin.json`; if + a future host only reads that form, dual-home the manifest there. From 6d1fada5a41f99e00e2d0be627c793b41c4cb193 Mon Sep 17 00:00:00 2001 From: wenjiefan Date: Tue, 4 Aug 2026 10:41:03 +0200 Subject: [PATCH 37/86] Add FP guards for field relocation to tableextension and event parameter addition Two knowledge false-positive guards addressing bug 642303 (agent FPs on BCApps PR #9607): - breaking-changes: relocating a field to a tableextension in the same app under the same field ID/name is a relocation, not a deletion/rename; the field still resolves on the table, so it must not be flagged as a deleted shipped field or require ObsoleteState staging. Scoped to the contract axis; silent on data migration. - events: adding a parameter to an event publisher does not break existing subscribers (subscribers bind by name and match a subset), so the addition itself must not be reported as a breaking signature change. --- ...ld-to-a-tableextension-is-not-a-deletion.md | 18 ++++++++++++++++++ ...ter-to-an-event-is-not-a-breaking-change.md | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 microsoft/knowledge/breaking-changes/relocating-a-field-to-a-tableextension-is-not-a-deletion.md create mode 100644 microsoft/knowledge/events/adding-a-parameter-to-an-event-is-not-a-breaking-change.md diff --git a/microsoft/knowledge/breaking-changes/relocating-a-field-to-a-tableextension-is-not-a-deletion.md b/microsoft/knowledge/breaking-changes/relocating-a-field-to-a-tableextension-is-not-a-deletion.md new file mode 100644 index 0000000..d37bfe8 --- /dev/null +++ b/microsoft/knowledge/breaking-changes/relocating-a-field-to-a-tableextension-is-not-a-deletion.md @@ -0,0 +1,18 @@ +--- +bc-version: [all] +domain: breaking-changes +keywords: [table-field, tableextension, relocation, field-id, obsoletestate, breaking-change, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Relocating a field to a tableextension in the same app is not a deletion + +## Description + +Moving a field out of a base-table definition (or a base-app layer modification of one) into a tableextension that `extends` the same table, within the same app and keeping the same field ID and name, is a relocation — not a deletion or a rename. After the move the field still exists on the table: `Rec."Field Name"` and the field ID resolve exactly as before, so dependent extensions that reference the field continue to compile. Nothing in the field's public contract is removed or renamed, so the deprecation lifecycle that protects a genuinely removed field does not apply. LLM reviewers frequently misread the two-sided diff — the field disappearing from the base object and reappearing in the tableextension — as a shipped field being deleted and illegally re-added under the same ID, and demand `ObsoleteState = Pending` staging that this refactor does not need. + +## Best Practice + +Recognize a field that is removed from a base table (or base-app layer) and re-declared in a tableextension of the same table, with the same field ID and name, as a same-app relocation. Do not flag it as a deleted or renamed shipped field, and do not require `ObsoleteState = Pending`, `ObsoleteReason`, `ObsoleteTag`, or a deprecation window for the move itself. The `obsolete-table-fields-instead-of-deleting-them` and `obsolete-pending-to-removed-staging` rules apply to fields that leave the table's contract entirely, not to fields relocated within the same app under an unchanged ID. diff --git a/microsoft/knowledge/events/adding-a-parameter-to-an-event-is-not-a-breaking-change.md b/microsoft/knowledge/events/adding-a-parameter-to-an-event-is-not-a-breaking-change.md new file mode 100644 index 0000000..330f9bf --- /dev/null +++ b/microsoft/knowledge/events/adding-a-parameter-to-an-event-is-not-a-breaking-change.md @@ -0,0 +1,18 @@ +--- +bc-version: [all] +domain: events +keywords: [event-parameters, signature, subscriber-binding, backward-compatibility, integration-event, breaking-change, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Adding a parameter to an event is not a breaking change + +## Description + +Adding a parameter to an existing event publisher does not break existing subscribers. AL binds a subscriber to a publisher by the event name, and the subscriber's parameter list only has to be a subset of the publisher's, matched by name and type. A subscriber that does not declare the new parameter keeps compiling and keeps binding — it simply ignores the addition. This holds for `IntegrationEvent` and `BusinessEvent` publishers, and even more plainly for `local` events. Appending the new parameter at the end keeps the change a clean, reviewable addition (see `add-new-event-parameters-at-the-end`). LLM reviewers often misreport the mere presence of a new event parameter as a "breaking event signature change" that breaks subscribers, which is incorrect. + +## Best Practice + +Do not flag the addition of a parameter to an event publisher as a breaking or signature-breaking change, and do not claim it breaks existing subscribers. Genuine, separate concerns are covered by their own rules — a parameter inserted in the middle of the list rather than appended (`add-new-event-parameters-at-the-end`), or a parameter that carries no meaningful value — and should be raised on those grounds, not framed as a backward-compatibility break. From a58b23d0f905740a1464ef908bc36921c26f1fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20Maro=C5=84?= Date: Mon, 10 Aug 2026 13:11:38 +0200 Subject: [PATCH 38/86] knowledge(data-modeling): TableRelation delete/rename asymmetry and the xRec before-image contract (#125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related concerns, each proven by executable tests rather than recall. They were extracted from a real defect that shipped through a six-reviewer panel undetected, which is the admission test passing on behavior rather than on theory. owning-table-must-delete-dependents-in-ondelete AL has no cascading delete. What makes it missable is an asymmetry: the platform DOES keep references correct on rename via TableRelation, so a developer who learns that and generalizes it to delete ships orphans. Also notes the permission trap (delete rights needed on the dependent table, not just the parent). validate-table-relation-false-suppresses-rename-propagation The non-obvious half. The property name implies input validation only, but disabling it also switches off rename propagation. Verified against a parent renamed once while a child held three fields: a normal relation (follows), the same relation with validation disabled (does NOT follow), and a field with no relation at all (does not follow) — the third being the control that proves the test can detect a non-propagating field. xrec-is-a-before-image-only-in-some-triggers Corrects both the naive belief that xRec is always the previous record and the folk rule that it 'only works from a page'. The behavior is per-trigger: a genuine before-image in OnRename and OnDelete regardless of driver, a mirror of Rec in OnInsert/OnModify when driven from code, and a real before-image in those two only when a page drove the write. That last asymmetry is why an OnModify comparison against xRec passes manual page testing and silently no-ops in a job queue. Targets /community per CONTRIBUTING — general BC knowledge, not fork-specific. Frontmatter validator clean; Test-ReviewFixtures passes (32 cases, 16 leaves). --- ...-must-delete-dependents-in-ondelete.bad.al | 48 +++++++++++++ ...must-delete-dependents-in-ondelete.good.al | 55 +++++++++++++++ ...able-must-delete-dependents-in-ondelete.md | 36 ++++++++++ ...false-suppresses-rename-propagation.bad.al | 35 ++++++++++ ...alse-suppresses-rename-propagation.good.al | 70 +++++++++++++++++++ ...ion-false-suppresses-rename-propagation.md | 38 ++++++++++ ...-before-image-only-in-some-triggers.bad.al | 36 ++++++++++ ...before-image-only-in-some-triggers.good.al | 58 +++++++++++++++ ...is-a-before-image-only-in-some-triggers.md | 36 ++++++++++ 9 files changed, 412 insertions(+) create mode 100644 community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.bad.al create mode 100644 community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.good.al create mode 100644 community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md create mode 100644 community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.bad.al create mode 100644 community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.good.al create mode 100644 community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md create mode 100644 community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.bad.al create mode 100644 community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.good.al create mode 100644 community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md diff --git a/community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.bad.al b/community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.bad.al new file mode 100644 index 0000000..35d8eef --- /dev/null +++ b/community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.bad.al @@ -0,0 +1,48 @@ +table 50100 "Order Header" +{ + fields + { + field(1; "Entry No."; Integer) + { + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } + + // No OnDelete. Deleting a header silently orphans every Order Line that + // belonged to it. Nothing errors, and no page shows the stranded rows. +} + +table 50101 "Order Line" +{ + fields + { + field(1; "Line No."; Integer) + { + DataClassification = CustomerContent; + } + field(2; "Header Entry No."; Integer) + { + DataClassification = CustomerContent; + // Reads like referential integrity. It is lookup and input validation + // only: it propagates a RENAME of the parent key, and cascades nothing + // on DELETE. + TableRelation = "Order Header"."Entry No."; + } + } + + keys + { + key(PK; "Line No.") + { + Clustered = true; + } + } +} diff --git a/community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.good.al b/community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.good.al new file mode 100644 index 0000000..e7f5dd7 --- /dev/null +++ b/community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.good.al @@ -0,0 +1,55 @@ +table 50100 "Order Header" +{ + // The owning table needs delete rights on what it owns. Granting D only on the + // header is a common miss and makes OnDelete fail for a non-SUPER user. + Permissions = tabledata "Order Line" = rd; + + fields + { + field(1; "Entry No."; Integer) + { + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } + + trigger OnDelete() + var + OrderLine: Record "Order Line"; + begin + OrderLine.SetRange("Header Entry No.", "Entry No."); + // Pass false only when Order Line has no OnDelete of its own. + OrderLine.DeleteAll(true); + end; +} + +table 50101 "Order Line" +{ + fields + { + field(1; "Line No."; Integer) + { + DataClassification = CustomerContent; + } + field(2; "Header Entry No."; Integer) + { + DataClassification = CustomerContent; + TableRelation = "Order Header"."Entry No."; + } + } + + keys + { + key(PK; "Line No.") + { + Clustered = true; + } + } +} diff --git a/community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md b/community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md new file mode 100644 index 0000000..82bf81d --- /dev/null +++ b/community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md @@ -0,0 +1,36 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [ondelete, cascade, table-relation, orphan-records, header-line, dependent-records, referential-integrity] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A table that owns dependent records must delete them in `OnDelete` + +## Description + +`TableRelation` looks like referential integrity but only performs lookup and input validation. AL has **no cascading delete**: deleting a parent record leaves every dependent row untouched, and no error is raised. + +What makes this specifically missable is an asymmetry. The platform *does* keep references correct on **rename** — renaming a record updates it in all other locations that declare a `TableRelation` to it, with no code. Delete has no equivalent. Same relation, same metadata, opposite behaviour. A developer who correctly learns that `TableRelation` "keeps references consistent" from the rename case, and generalises it to delete, ships orphans. + +Orphaned rows are usually invisible, because a dependent table rarely has a page of its own. They inflate the table, break later reconciliation, and are re-encountered by duplicate checks when the parent key is reused. + +This applies to internal, staging and `SystemMetadata` tables too. A table having no delete action in the UI today is not protection: a permission set that grants `D` on the table is evidence that deletion is anticipated. + +See also `validate-table-relation-false-suppresses-rename-propagation.md` for the two preconditions on the rename half of this asymmetry. + +## Best Practice + +The owning table implements `OnDelete` and deletes its dependents there, filtered on the foreign key. Declare `Permissions = tabledata = rd` on the owning table — granting delete rights only on the parent is a common miss that makes the trigger fail for a non-`SUPER` user. This mirrors the base application, where every header table deletes its own lines. + +See sample: `owning-table-must-delete-dependents-in-ondelete.good.al`. + +## Anti Pattern + +A parent table with dependent rows and no `OnDelete` trigger, where the dependent's foreign-key field declares a `TableRelation` back to the parent. The relation reads as if it guarantees integrity; it does not. + +Detection signal: a table declares `TableRelation` to table X, and table X has no `OnDelete` trigger. Whether a delete path currently exists in the UI is irrelevant to the finding. + +See sample: `owning-table-must-delete-dependents-in-ondelete.bad.al`. diff --git a/community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.bad.al b/community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.bad.al new file mode 100644 index 0000000..94b8963 --- /dev/null +++ b/community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.bad.al @@ -0,0 +1,35 @@ +table 50121 "Document Link" +{ + fields + { + field(1; "Entry No."; Integer) + { + DataClassification = CustomerContent; + } + field(2; "Document No."; Code[20]) + { + DataClassification = CustomerContent; + TableRelation = "Source Document"."No."; + // Added to silence a validation error while the row is staged, before + // the Source Document exists. The relation is still declared, so this + // reads as harmless — but it also switches OFF rename propagation. + // Renaming a Source Document now leaves this field on the old key, + // with no error, and nothing else maintains it. + ValidateTableRelation = false; + } + // Composite value: no TableRelation is possible, and no OnRename on the + // owning table maintains it either. Rots the same way, for the other reason. + field(3; "Source Key"; Code[50]) + { + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } +} diff --git a/community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.good.al b/community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.good.al new file mode 100644 index 0000000..6f6c453 --- /dev/null +++ b/community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.good.al @@ -0,0 +1,70 @@ +table 50120 "Source Document" +{ + fields + { + field(1; "No."; Code[20]) + { + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + } + + // "Source Key" below cannot declare a TableRelation, so the platform cannot + // repoint it. The owning table carries the relationship by hand. + trigger OnRename() + var + DocumentLink: Record "Document Link"; + begin + // In OnRename, xRec holds the PREVIOUS primary key while Rec holds the new + // one — the one trigger where that is true regardless of what drove the rename. + DocumentLink.SetRange("Source Key", MakeSourceKey(xRec."No.")); + if DocumentLink.FindSet(true) then + repeat + DocumentLink."Source Key" := MakeSourceKey(Rec."No."); + DocumentLink.Modify(true); + until DocumentLink.Next() = 0; + end; + + local procedure MakeSourceKey(DocumentNo: Code[20]): Code[50] + begin + exit(StrSubstNo('DOC|%1', DocumentNo)); + end; +} + +table 50121 "Document Link" +{ + fields + { + field(1; "Entry No."; Integer) + { + DataClassification = CustomerContent; + } + // Default ValidateTableRelation: the platform repoints this on rename. + field(2; "Document No."; Code[20]) + { + DataClassification = CustomerContent; + TableRelation = "Source Document"."No."; + } + // Composite value — no TableRelation can express it, so the parent's + // OnRename above maintains it explicitly. + field(3; "Source Key"; Code[50]) + { + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } +} diff --git a/community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md b/community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md new file mode 100644 index 0000000..ddd4856 --- /dev/null +++ b/community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md @@ -0,0 +1,38 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [validatetablerelation, table-relation, rename, onrename, propagation, dangling-reference, soft-relation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `ValidateTableRelation = false` suppresses rename propagation, not just input validation + +## Description + +Renaming a record updates it in all other locations that reference it through a `TableRelation`, with no code. That guarantee has **two** preconditions, and a field failing either one is silently left holding a key that no longer exists. + +First, a `TableRelation` must exist. A field whose value is constructed or computed — a composite key, or a value derived from several fields of the target — cannot declare one, so nothing propagates. The field is still a foreign key in intent, but the platform treats it as an opaque value. + +Second, and far less obvious: the relation must not carry `ValidateTableRelation = false`. The property name implies it only governs *input validation*, so it looks safe to disable on a field populated by code that already knows the target is valid. It is not. **Disabling it also switches off rename propagation.** The relation still documents intent and still drives lookups, but it no longer keeps the stored value correct. + +Both failures are quiet: no error at rename time, and in the first case no input validation either, so a wrong value is never rejected on write. + +This is verified behaviour, not inference. A parent renamed once against a child holding three fields — a normal relation, the same relation with `ValidateTableRelation = false`, and a field with no relation — updates only the first. + +See also `owning-table-must-delete-dependents-in-ondelete.md` for the delete half of this asymmetry, and `xrec-is-a-before-image-only-in-some-triggers.md` for why `OnRename` is the one trigger where a hand-written fix-up is reliable. + +## Best Practice + +Leave `ValidateTableRelation` at its default wherever the stored value must stay correct across a rename. When it must be disabled, or when the relationship cannot be expressed as a `TableRelation` at all, the table owning the referenced key carries an explicit `OnRename` that repoints the dependents itself. + +See sample: `validate-table-relation-false-suppresses-rename-propagation.good.al`. + +## Anti Pattern + +`ValidateTableRelation = false` added to silence a validation error, on a field expected to keep tracking its target. The field stops being maintained on rename, and the defect surfaces much later as a reference to a key that no longer exists. + +Detection signal: any `ValidateTableRelation = false` on a field that also declares a `TableRelation`. Ask what repoints the value when the target is renamed; if the answer is "the platform", the finding stands. + +See sample: `validate-table-relation-false-suppresses-rename-propagation.bad.al`. diff --git a/community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.bad.al b/community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.bad.al new file mode 100644 index 0000000..cfd4d8b --- /dev/null +++ b/community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.bad.al @@ -0,0 +1,36 @@ +table 50130 "Service Request" +{ + fields + { + field(1; "No."; Code[20]) + { + DataClassification = CustomerContent; + } + field(2; Status; Enum "Service Request Status") + { + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + } + + trigger OnModify() + begin + // Dead branch under any code-driven Modify: from code xRec mirrors Rec, so + // the two Status values are always equal and LogStatusChange never runs. + // Editing the field on a page DOES populate xRec, so this passes manual + // testing and then silently does nothing in a job queue or API call. + if Status <> xRec.Status then + LogStatusChange(xRec.Status, Status); + end; + + local procedure LogStatusChange(FromStatus: Enum "Service Request Status"; ToStatus: Enum "Service Request Status") + begin + end; +} diff --git a/community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.good.al b/community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.good.al new file mode 100644 index 0000000..3b92352 --- /dev/null +++ b/community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.good.al @@ -0,0 +1,58 @@ +table 50130 "Service Request" +{ + fields + { + field(1; "No."; Code[20]) + { + DataClassification = CustomerContent; + } + field(2; Status; Enum "Service Request Status") + { + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + } + + // OnModify: xRec mirrors Rec when the write came from code, so it cannot be + // used as a before-image. Re-read the stored row instead — this behaves the + // same whether a page, a job queue or an API drove the write. + trigger OnModify() + var + Previous: Record "Service Request"; + begin + if Previous.Get("No.") and (Previous.Status <> Status) then + LogStatusChange(Previous.Status, Status); + end; + + // OnRename: xRec IS the before-image of the primary key here, whatever drove + // the rename. This is the one trigger where the idiom is reliable. + trigger OnRename() + begin + RepointDependents(xRec."No.", "No."); + end; + + // OnDelete: xRec reflects the record being removed. + trigger OnDelete() + begin + ArchiveRequest(xRec."No."); + end; + + local procedure LogStatusChange(FromStatus: Enum "Service Request Status"; ToStatus: Enum "Service Request Status") + begin + end; + + local procedure RepointDependents(OldNo: Code[20]; NewNo: Code[20]) + begin + end; + + local procedure ArchiveRequest(RequestNo: Code[20]) + begin + end; +} diff --git a/community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md b/community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md new file mode 100644 index 0000000..52c06bd --- /dev/null +++ b/community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md @@ -0,0 +1,36 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [xrec, before-image, onmodify, onrename, oninsert, ondelete, table-trigger, page-driven] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `xRec` is a before-image in `OnRename` and `OnDelete`, but mirrors `Rec` in `OnInsert` and `OnModify` from code + +## Description + +`xRec` is widely believed to be "the previous record" inside every table trigger, and — in reaction to that — is often dismissed with the folk rule *"`xRec` only works from a page, never from code"*. Both are wrong, and the second is wrong in the place it matters most. + +The behaviour is per-trigger. In `OnRename` and `OnDelete`, `xRec` is a genuine before-image regardless of what drove the write. In `OnInsert` and `OnModify`, a **code-driven** write leaves `xRec` mirroring `Rec` — there is no before-image at all — while a **page-driven** write does supply one. + +That combination produces a defect that is unusually hard to catch. A comparison such as `if Rec.Status <> xRec.Status then` inside `OnModify` works when a tester clicks through a page, and silently never fires when the same code path runs from a job queue, a batch routine, or an API call. It fails as a no-op, not as an error. + +The `OnRename` case is the useful half: because `xRec` there holds the previous primary key even from code, it is the one place a hand-written key fix-up is reliable. Note that in `OnRename` only the key differs between `Rec` and `xRec` — non-key field values are identical on both sides. + +See also `validate-table-relation-false-suppresses-rename-propagation.md`, which describes when such a hand-written `OnRename` fix-up is required. + +## Best Practice + +Use `xRec` for the previous key in `OnRename`, and for the record being removed in `OnDelete`. In `OnModify`, obtain the before-image by re-reading the stored row rather than trusting `xRec`, so the logic behaves identically whether a page, a job queue or an API drove the write. + +See sample: `xrec-is-a-before-image-only-in-some-triggers.good.al`. + +## Anti Pattern + +Comparing `Rec` against `xRec` inside `OnModify` (or `OnInsert`) to detect a change. From code the two are equal, so the branch is dead and whatever it guards never happens. + +Detection signal: any read of `xRec` inside `OnModify` or `OnInsert`. Treat "but it works when I test it on the page" as confirmation of the defect rather than a refutation. + +See sample: `xrec-is-a-before-image-only-in-some-triggers.bad.al`. From 91f52f8b4fa479ec75842dd573903115326881d0 Mon Sep 17 00:00:00 2001 From: Dan Fiedler <151573964+danfiedler-msft@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:24:42 -0400 Subject: [PATCH 39/86] Pin GitHub Actions to full-length commit SHAs (#126) --- .github/dependabot.yml | 11 +++++++++++ .github/workflows/flag-new-top-level.yml | 4 ++-- .github/workflows/guard-custom-layer.yml | 4 ++-- .github/workflows/knowledge-index.yml | 2 +- .github/workflows/release-version.yml | 2 +- .github/workflows/review-fixtures.yml | 2 +- .github/workflows/validate-frontmatter.yml | 4 ++-- 7 files changed, 20 insertions(+), 9 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2c48305 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + groups: + github-actions: + patterns: ["*"] + schedule: + interval: "weekly" + cooldown: + default-days: 7 diff --git a/.github/workflows/flag-new-top-level.yml b/.github/workflows/flag-new-top-level.yml index c3a5c2d..21cce68 100644 --- a/.github/workflows/flag-new-top-level.yml +++ b/.github/workflows/flag-new-top-level.yml @@ -25,14 +25,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: sparse-checkout: | .github/new-top-level-flag.md sparse-checkout-cone-mode: false - name: Flag unexpected new top-level entries - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const fs = require('fs'); diff --git a/.github/workflows/guard-custom-layer.yml b/.github/workflows/guard-custom-layer.yml index c061389..64716e3 100644 --- a/.github/workflows/guard-custom-layer.yml +++ b/.github/workflows/guard-custom-layer.yml @@ -30,14 +30,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: sparse-checkout: | .github/custom-layer-autoclose.md sparse-checkout-cone-mode: false - name: Close PR if it touches the custom layer - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const fs = require('fs'); diff --git a/.github/workflows/knowledge-index.yml b/.github/workflows/knowledge-index.yml index 71acfcb..0b8765b 100644 --- a/.github/workflows/knowledge-index.yml +++ b/.github/workflows/knowledge-index.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Validate knowledge-index generator shell: pwsh diff --git a/.github/workflows/release-version.yml b/.github/workflows/release-version.yml index 36f80f3..ad29dae 100644 --- a/.github/workflows/release-version.yml +++ b/.github/workflows/release-version.yml @@ -34,7 +34,7 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 diff --git a/.github/workflows/review-fixtures.yml b/.github/workflows/review-fixtures.yml index fff9cd0..f20011b 100644 --- a/.github/workflows/review-fixtures.yml +++ b/.github/workflows/review-fixtures.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Validate review evaluation corpus shell: pwsh diff --git a/.github/workflows/validate-frontmatter.yml b/.github/workflows/validate-frontmatter.yml index 1a66239..17cbbf6 100644 --- a/.github/workflows/validate-frontmatter.yml +++ b/.github/workflows/validate-frontmatter.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" From 455035432db7f1092f22247ee881a3223136b963 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:51:59 +0200 Subject: [PATCH 40/86] Bump the github-actions group with 3 updates (#127) Bumps the github-actions group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [actions/github-script](https://github.com/actions/github-script) and [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/checkout` from 4.4.0 to 7.0.1 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/11d5960a326750d5838078e36cf38b85af677262...3d3c42e5aac5ba805825da76410c181273ba90b1) Updates `actions/github-script` from 7.1.0 to 9.0.0 - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/f28e40c7f34bde8b3046d885e986cb6290c5673b...3a2844b7e9c422d3c10d287c895573f7108da1b3) Updates `actions/setup-python` from 5.6.0 to 7.0.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/a26af69be951a213d495a4c3e4e4022e16d87065...5fda3b95a4ea91299a34e894583c3862153e4b97) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/github-script dependency-version: 9.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-python dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/flag-new-top-level.yml | 4 ++-- .github/workflows/guard-custom-layer.yml | 4 ++-- .github/workflows/knowledge-index.yml | 2 +- .github/workflows/release-version.yml | 2 +- .github/workflows/review-fixtures.yml | 2 +- .github/workflows/validate-frontmatter.yml | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/flag-new-top-level.yml b/.github/workflows/flag-new-top-level.yml index 21cce68..31ab105 100644 --- a/.github/workflows/flag-new-top-level.yml +++ b/.github/workflows/flag-new-top-level.yml @@ -25,14 +25,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: sparse-checkout: | .github/new-top-level-flag.md sparse-checkout-cone-mode: false - name: Flag unexpected new top-level entries - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const fs = require('fs'); diff --git a/.github/workflows/guard-custom-layer.yml b/.github/workflows/guard-custom-layer.yml index 64716e3..80c5851 100644 --- a/.github/workflows/guard-custom-layer.yml +++ b/.github/workflows/guard-custom-layer.yml @@ -30,14 +30,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: sparse-checkout: | .github/custom-layer-autoclose.md sparse-checkout-cone-mode: false - name: Close PR if it touches the custom layer - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const fs = require('fs'); diff --git a/.github/workflows/knowledge-index.yml b/.github/workflows/knowledge-index.yml index 0b8765b..8cb086d 100644 --- a/.github/workflows/knowledge-index.yml +++ b/.github/workflows/knowledge-index.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Validate knowledge-index generator shell: pwsh diff --git a/.github/workflows/release-version.yml b/.github/workflows/release-version.yml index ad29dae..92af86e 100644 --- a/.github/workflows/release-version.yml +++ b/.github/workflows/release-version.yml @@ -34,7 +34,7 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/review-fixtures.yml b/.github/workflows/review-fixtures.yml index f20011b..0f39d9a 100644 --- a/.github/workflows/review-fixtures.yml +++ b/.github/workflows/review-fixtures.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Validate review evaluation corpus shell: pwsh diff --git a/.github/workflows/validate-frontmatter.yml b/.github/workflows/validate-frontmatter.yml index 17cbbf6..dd7922d 100644 --- a/.github/workflows/validate-frontmatter.yml +++ b/.github/workflows/validate-frontmatter.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" From a668920e96f5cc3d4686c5e457fd51bfa8fe47c0 Mon Sep 17 00:00:00 2001 From: wenjiefan Date: Mon, 17 Aug 2026 13:16:12 +0200 Subject: [PATCH 41/86] Correct table-level data classification guidance Document that valid table-level classifications are inherited by fields and update the privacy fixture and related guidance accordingly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../data-classification-required-on-pii-fields.md | 2 +- .../table-level-data-classification-cascades.good.al | 6 +++++- .../table-level-data-classification-cascades.md | 10 +++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md index d3e1e55..97c9858 100644 --- a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md @@ -11,7 +11,7 @@ application-area: [all] ## 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, customer, or organization data. When the property is omitted, AL applies `ToBeClassified` — a placeholder meaning "not yet reviewed", not a safe default. Leaving a field that actually holds PII (an email address, a customer name, an employee code) as `ToBeClassified`, or setting it to `SystemMetadata` ("no user or customer data") to silence the requirement, are both under-classifications and privacy bugs, even though the code still compiles. +`DataClassification` 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. A field can declare its own value or inherit the table-level value. When neither scope supplies a valid classification, the field remains `ToBeClassified` — a placeholder meaning "not yet reviewed", not a safe default. Leaving a field that actually holds PII (an email address, a customer name, an employee code) as `ToBeClassified`, or classifying it as `SystemMetadata` ("no user or customer data"), are both under-classifications and privacy bugs, even though the code still compiles. ## Best Practice diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al index 80a4345..11cbbb6 100644 --- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al @@ -1,10 +1,11 @@ table 50202 "System Configuration Log" { + DataClassification = SystemMetadata; + fields { field(1; "Entry No."; Integer) { - DataClassification = SystemMetadata; } field(2; "Changed By"; Code[50]) { @@ -14,6 +15,9 @@ table 50202 "System Configuration Log" { DataClassification = CustomerContent; } + field(4; "Changed At"; DateTime) + { + } } keys diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md index cd31d07..55ac5d6 100644 --- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md @@ -1,24 +1,24 @@ --- bc-version: [all] domain: privacy -keywords: [data-classification, table-level, normal-field, appsourcecop, as0016] +keywords: [data-classification, table-level, field-inheritance, appsourcecop, as0016, false-positive] technologies: [al] countries: [w1] application-area: [all] --- -# Set DataClassification on every Normal table field +# Table-level DataClassification is inherited by fields ## Description -AppSourceCop AS0016 requires every field whose `FieldClass` is `Normal` to declare `DataClassification` and use a value other than `ToBeClassified`. A table-level `DataClassification` property does not satisfy that field-level requirement. FlowFields and FlowFilters are handled separately by the platform and are covered by `flowfield-flowfilter-classification-systemmetadata.md`. +A valid table-level `DataClassification` is the effective default for fields that do not declare their own value. A field-level value overrides that default only for the field on which it is set. AppSourceCop AS0016 accepts Normal fields that inherit a valid table classification; they do not remain `ToBeClassified`. FlowFields and FlowFilters are handled separately by the platform and are covered by `flowfield-flowfilter-classification-systemmetadata.md`. ## Best Practice -Classify each Normal field according to the data it stores, even when every field in the table has the same classification. Repeat the property explicitly so AS0016 can verify every field. +Use a table-level classification when it accurately describes the table's fields, and add a field-level classification only where a field stores a different kind of data. Do not flag a Normal field solely because it omits an explicit property when its table supplies a valid default; verify whether the inherited value matches the field's data instead. See sample: `table-level-data-classification-cascades.good.al`. ## Anti Pattern -Relying on `DataClassification` at table scope and leaving Normal fields unclassified. The table property does not cascade in the way AS0016 requires, so the fields still fail AppSourceCop validation. +Reporting every Normal field without an explicit `DataClassification` when the table already supplies a valid default, or requiring redundant field-level declarations that repeat the table value. A real issue exists when neither scope supplies a valid classification, or when a field's data requires an override of the inherited value. From f8acb6cbddc35e410c501da382adcbc1b3c2ef9c Mon Sep 17 00:00:00 2001 From: wenjiefan Date: Mon, 17 Aug 2026 15:14:53 +0200 Subject: [PATCH 42/86] Scope DataClassification inheritance to fields declared in the table Table-level DataClassification is the effective default only for Normal fields declared inside that table object. A tableextension cannot set the property (AL0246) and its added fields do not inherit the base table value, so AS0016 still requires each of them to classify itself. State this in both privacy articles so the guidance cannot suppress genuine findings on the tableextension pattern, which is how most partner code adds fields. Also narrow the inheritance claim to verified AppSourceCop behaviour rather than asserting platform-level resolution, and make the sample's table-level default semantically representative of its fields while keeping a legitimate field-level override and demonstrating the tableextension boundary. Verified with alc.exe 18.0.37.11445 + Microsoft.Dynamics.Nav.AppSourceCop.dll: the revised sample produces no AS0016, and removing the explicit classification from the tableextension field makes AS0016 fire. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...a-classification-required-on-pii-fields.md | 2 +- ...level-data-classification-cascades.good.al | 29 ++++++++++++++----- ...able-level-data-classification-cascades.md | 8 ++--- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md index 97c9858..e377256 100644 --- a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md @@ -11,7 +11,7 @@ application-area: [all] ## Description -`DataClassification` 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. A field can declare its own value or inherit the table-level value. When neither scope supplies a valid classification, the field remains `ToBeClassified` — a placeholder meaning "not yet reviewed", not a safe default. Leaving a field that actually holds PII (an email address, a customer name, an employee code) as `ToBeClassified`, or classifying it as `SystemMetadata` ("no user or customer data"), are both under-classifications and privacy bugs, even though the code still compiles. +`DataClassification` 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. A field declared inside a table object can set its own value or inherit a valid table-level value; a field added by a `tableextension` has no table-level value to inherit and must always set its own. When neither scope supplies a valid classification, the field remains `ToBeClassified` — a placeholder meaning "not yet reviewed", not a safe default. Leaving a field that actually holds PII (an email address, a customer name, an employee code) as `ToBeClassified`, or classifying it as `SystemMetadata` ("no user or customer data"), are both under-classifications and privacy bugs, even though the code still compiles. ## Best Practice diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al index 11cbbb6..bb0a8cf 100644 --- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al @@ -7,17 +7,16 @@ table 50202 "System Configuration Log" field(1; "Entry No."; Integer) { } - field(2; "Changed By"; Code[50]) + field(2; "Setting Name"; Text[100]) + { + } + field(3; "Changed At"; DateTime) + { + } + field(4; "Changed By"; Code[50]) { DataClassification = EndUserIdentifiableInformation; } - field(3; "Change Description"; Text[250]) - { - DataClassification = CustomerContent; - } - field(4; "Changed At"; DateTime) - { - } } keys @@ -25,3 +24,17 @@ table 50202 "System Configuration Log" key(PK; "Entry No.") { Clustered = true; } } } + +tableextension 50203 "System Config Log Correlation" extends "System Configuration Log" +{ + fields + { + // A table extension cannot set the table-level property and does not inherit + // the base table's default, so this field must classify itself even though + // SystemMetadata is the value the base table already declares. + field(50203; "Correlation Id"; Guid) + { + DataClassification = SystemMetadata; + } + } +} diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md index 55ac5d6..68b5920 100644 --- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: privacy -keywords: [data-classification, table-level, field-inheritance, appsourcecop, as0016, false-positive] +keywords: [data-classification, table-level, field-inheritance, tableextension, appsourcecop, as0016, false-positive] technologies: [al] countries: [w1] application-area: [all] @@ -11,14 +11,14 @@ application-area: [all] ## Description -A valid table-level `DataClassification` is the effective default for fields that do not declare their own value. A field-level value overrides that default only for the field on which it is set. AppSourceCop AS0016 accepts Normal fields that inherit a valid table classification; they do not remain `ToBeClassified`. FlowFields and FlowFilters are handled separately by the platform and are covered by `flowfield-flowfilter-classification-systemmetadata.md`. +A valid table-level `DataClassification` is the effective default for the Normal fields declared inside that table object when they do not declare their own value, and AppSourceCop AS0016 accepts those fields rather than reporting them as unclassified. A field-level value overrides that default only for the field on which it is set. The default does not cross object boundaries: a `tableextension` cannot set the table-level property, and the fields it adds do not inherit the base table's value, so every Normal field a table extension adds must classify itself. FlowFields and FlowFilters are handled separately by the platform and are covered by `flowfield-flowfilter-classification-systemmetadata.md`. ## Best Practice -Use a table-level classification when it accurately describes the table's fields, and add a field-level classification only where a field stores a different kind of data. Do not flag a Normal field solely because it omits an explicit property when its table supplies a valid default; verify whether the inherited value matches the field's data instead. +Use a table-level classification when it accurately describes the table's fields, and add a field-level classification only where a field stores a different kind of data. Do not flag a Normal field solely because it omits an explicit property when its own table supplies a valid default; verify whether the inherited value matches the field's data instead. A `tableextension` has no default to inherit, so require an explicit `DataClassification` on every Normal field it adds. See sample: `table-level-data-classification-cascades.good.al`. ## Anti Pattern -Reporting every Normal field without an explicit `DataClassification` when the table already supplies a valid default, or requiring redundant field-level declarations that repeat the table value. A real issue exists when neither scope supplies a valid classification, or when a field's data requires an override of the inherited value. +Reporting every Normal field without an explicit `DataClassification` when its own table already supplies a valid default, or requiring redundant field-level declarations that repeat the table value. The mirror-image mistake is waving through an unclassified Normal field added by a `tableextension` because the base table carries a default — a table extension inherits nothing. A real issue exists when neither scope supplies a valid classification, when a field's data requires an override of the inherited value, or when the field is added by a table extension. From c1057d38b29f439f0d36ffa5265cd73822baa03c Mon Sep 17 00:00:00 2001 From: wenjiefan Date: Mon, 17 Aug 2026 15:25:14 +0200 Subject: [PATCH 43/86] Scope tableextension requirement to Normal fields lacking a classification The requirement to declare DataClassification explicitly in a tableextension applies to the Normal fields it adds; FlowFields and FlowFilters are SystemMetadata automatically and are covered by their own article. Being added by a table extension is also not itself a finding - the finding is a Normal field added by a table extension that has no valid explicit DataClassification. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../privacy/data-classification-required-on-pii-fields.md | 2 +- .../privacy/table-level-data-classification-cascades.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md index e377256..b431c12 100644 --- a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md @@ -11,7 +11,7 @@ application-area: [all] ## Description -`DataClassification` 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. A field declared inside a table object can set its own value or inherit a valid table-level value; a field added by a `tableextension` has no table-level value to inherit and must always set its own. When neither scope supplies a valid classification, the field remains `ToBeClassified` — a placeholder meaning "not yet reviewed", not a safe default. Leaving a field that actually holds PII (an email address, a customer name, an employee code) as `ToBeClassified`, or classifying it as `SystemMetadata` ("no user or customer data"), are both under-classifications and privacy bugs, even though the code still compiles. +`DataClassification` 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. A field declared inside a table object can set its own value or inherit a valid table-level value; a `tableextension` has no table-level value to inherit, so every Normal field it adds must set its own. When neither scope supplies a valid classification, the field remains `ToBeClassified` — a placeholder meaning "not yet reviewed", not a safe default. Leaving a field that actually holds PII (an email address, a customer name, an employee code) as `ToBeClassified`, or classifying it as `SystemMetadata` ("no user or customer data"), are both under-classifications and privacy bugs, even though the code still compiles. ## Best Practice diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md index 68b5920..253d2cc 100644 --- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md @@ -21,4 +21,4 @@ See sample: `table-level-data-classification-cascades.good.al`. ## Anti Pattern -Reporting every Normal field without an explicit `DataClassification` when its own table already supplies a valid default, or requiring redundant field-level declarations that repeat the table value. The mirror-image mistake is waving through an unclassified Normal field added by a `tableextension` because the base table carries a default — a table extension inherits nothing. A real issue exists when neither scope supplies a valid classification, when a field's data requires an override of the inherited value, or when the field is added by a table extension. +Reporting every Normal field without an explicit `DataClassification` when its own table already supplies a valid default, or requiring redundant field-level declarations that repeat the table value. The mirror-image mistake is waving through an unclassified Normal field added by a `tableextension` because the base table carries a default — a table extension inherits nothing. A real issue exists when neither scope supplies a valid classification, when a field's data requires an override of the inherited value, or when a Normal field added by a `tableextension` lacks a valid explicit `DataClassification`. From 5f1cff2fb6427c14cf9b2ef1f790ee404a144b9e Mon Sep 17 00:00:00 2001 From: wenjiefan Date: Tue, 18 Aug 2026 11:17:14 +0200 Subject: [PATCH 44/86] Refine self-improvement review guidance Narrow IsHandled, label-scope, UI-handler, checkpoint, and bulk-operation guidance to evidence-backed false-positive boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...shandled-to-false-before-publishing.bad.al | 6 +- ...handled-to-false-before-publishing.good.al | 16 +++--- ...ze-ishandled-to-false-before-publishing.md | 8 +-- .../avoid-commit-inside-loops.good.al | 25 ++++++++- .../performance/avoid-commit-inside-loops.md | 8 ++- .../prefer-modifyall-over-per-row-modify.md | 4 +- ...ggers-and-media-field-regress-modifyall.md | 8 +-- .../labels-declared-at-object-scope.bad.al | 11 ---- .../labels-declared-at-object-scope.good.al | 13 ----- .../style/labels-declared-at-object-scope.md | 20 ++----- .../testing/ui-handlers-in-tests.bad.al | 45 +++++---------- .../testing/ui-handlers-in-tests.good.al | 55 +++++-------------- .../knowledge/testing/ui-handlers-in-tests.md | 12 ++-- microsoft/skills/review/al-events-review.md | 2 +- .../skills/review/al-performance-review.md | 5 +- microsoft/skills/review/al-privacy-review.md | 2 +- microsoft/skills/review/al-security-review.md | 2 +- microsoft/skills/review/al-style-review.md | 4 +- microsoft/skills/review/al-testing-review.md | 4 +- microsoft/skills/review/al-ui-review.md | 2 +- microsoft/skills/review/al-upgrade-review.md | 2 +- 21 files changed, 99 insertions(+), 155 deletions(-) delete mode 100644 microsoft/knowledge/style/labels-declared-at-object-scope.bad.al delete mode 100644 microsoft/knowledge/style/labels-declared-at-object-scope.good.al diff --git a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.bad.al b/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.bad.al index 94b50f3..2cb465d 100644 --- a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.bad.al +++ b/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.bad.al @@ -6,14 +6,12 @@ codeunit 50241 "IsHandled Init Bad Sample" DiscountPct: Decimal; IsHandled: Boolean; begin - // IsHandled is never initialized before the first raise, so flow depends - // on the variable's default rather than an explicit, documented intent. OnBeforeApplyHeaderDiscount(SalesHeader, DiscountPct, IsHandled); if not IsHandled then DiscountPct := 5; - // Bug: IsHandled is not reset. If the first subscriber set it true, the - // payment-discount default below is silently skipped too. + // Bug: execution continues when the first event set IsHandled to true, + // and that stale value is passed to a different publisher. OnBeforeApplyPaymentDiscount(SalesHeader, DiscountPct, IsHandled); if not IsHandled then DiscountPct += 2; diff --git a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.good.al b/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.good.al index 190e321..a595e8a 100644 --- a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.good.al +++ b/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.good.al @@ -6,17 +6,17 @@ codeunit 50240 "IsHandled Init Good Sample" DiscountPct: Decimal; IsHandled: Boolean; begin - IsHandled := false; + // A freshly declared local Boolean is false. OnBeforeApplyHeaderDiscount(SalesHeader, DiscountPct, IsHandled); - if not IsHandled then - DiscountPct := 5; + if IsHandled then + exit; + DiscountPct := 5; - // Reset before reusing the same variable for the next event so a - // subscriber that handled the first raise can't suppress this one. - IsHandled := false; + // Reaching this point proves that IsHandled is still false. OnBeforeApplyPaymentDiscount(SalesHeader, DiscountPct, IsHandled); - if not IsHandled then - DiscountPct += 2; + if IsHandled then + exit; + DiscountPct += 2; end; [IntegrationEvent(false, false)] diff --git a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.md b/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.md index acfb54a..9a2aef2 100644 --- a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.md +++ b/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.md @@ -7,20 +7,20 @@ countries: [w1] application-area: [all] --- -# Initialize IsHandled to false before publishing +# Reset IsHandled before publishing only when its value can carry over ## Description -A routine that raises an `OnBefore…` integration event with a `var IsHandled: Boolean` parameter passes that variable in by reference, so its incoming value decides whether the default logic is skipped. A freshly declared Boolean starts as `false`, but the same variable is frequently reused to raise several events in one routine, and after the first raise it may already be `true`. Assigning `IsHandled := false;` on the line immediately before every raise makes the control flow deterministic and self-documenting, and prevents a stale `true` from silently suppressing logic the author never meant to make skippable. Generated code often reuses one `IsHandled` across several raises without resetting it. +A routine that raises an `OnBefore…` integration event with a `var IsHandled: Boolean` parameter passes that variable by reference, so a pre-existing `true` can affect the following control flow. AL [automatically initializes Boolean variables to `false`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-al-variables#initialization), so a freshly declared local Boolean passed to one event is already deterministic. The same is true when control flow proves the variable is `false`; for example, reaching a second raise after `if IsHandled then exit;` proves that the first raise did not leave it `true`. ## Best Practice -Set `IsHandled := false;` immediately before each `OnBeforeX(…, IsHandled)` raise, then guard the default logic with `if IsHandled then exit;` or `if not IsHandled then …`. Do this even when the variable was just declared: the explicit reset documents intent and stays correct if a second event raise is added to the routine later. This applies only to events that carry a `var IsHandled: Boolean`; an `OnBefore` event with no `IsHandled` parameter needs no reset. +Reset `IsHandled := false;` before a raise only when the value might otherwise carry over as `true`: the same variable is reused after an earlier raise without a control-flow proof that it is false, the value comes from an input parameter, field, or global, or earlier code seeds it. A reset on a guaranteed-false fresh local can be retained for readability, but its absence is not a correctness finding. See sample: `initialize-ishandled-to-false-before-publishing.good.al`. ## Anti Pattern -Raising `OnBeforeX(…, IsHandled)` with a variable whose value carries over from an earlier raise, so a subscriber that handled the first event unintentionally suppresses the second routine's default logic. Detection: an `IsHandled` variable passed to more than one event in a routine without an intervening `IsHandled := false;`, or any `OnBefore…` raise that passes an `IsHandled` variable without an intervening `IsHandled := false;`. +Raising `OnBeforeX(…, IsHandled)` when the variable can still be `true` from an earlier raise or another source, so the new publisher call starts with stale state. Do not match a single raise using a fresh local Boolean, or a later raise reached only after `if IsHandled then exit;`. See sample: `initialize-ishandled-to-false-before-publishing.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al b/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al index 2ffa386..e82f98b 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al @@ -16,11 +16,18 @@ codeunit 50128 "Perf Sample CommitInLoop Good" { procedure NormalizeCustomerNames() var + NormalizeState: Record "Perf Normalize State"; LastCustomerNo: Code[20]; begin - // The outer loop owns checkpoints; the per-row loop contains no Commit. - while NormalizeNextChunk(LastCustomerNo) do + NormalizeState.Get('CUSTOMER'); + LastCustomerNo := NormalizeState."Last Customer No."; + + while NormalizeNextChunk(LastCustomerNo) do begin + // Persist progress in the same transaction as the completed chunk. + NormalizeState."Last Customer No." := LastCustomerNo; + NormalizeState.Modify(); Commit(); + end; end; local procedure NormalizeNextChunk(var LastCustomerNo: Code[20]): Boolean @@ -58,3 +65,17 @@ codeunit 50128 "Perf Sample CommitInLoop Good" exit(true); end; } + +table 50128 "Perf Normalize State" +{ + fields + { + field(1; Code; Code[10]) { } + field(2; "Last Customer No."; Code[20]) { } + } + + keys + { + key(PK; Code) { Clustered = true; } + } +} diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.md b/microsoft/knowledge/performance/avoid-commit-inside-loops.md index 13f483a..25ad958 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.md +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.md @@ -13,16 +13,18 @@ application-area: [all] ## Description -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 select an exact list of at most N keys and process only those rows. +Commit ends the current write transaction. Calling it inside a per-row loop usually produces one transaction per iteration and loses the ability to roll back the whole operation atomically; it also interferes with batching. Most loops need no explicit Commit at all — AL auto-commits the enclosing code module on successful completion (see `understand-implicit-transaction-boundary.md`). + +A durability checkpoint inside an outer batch loop can be valid only when the same transaction persists a progress marker or state that makes retries strictly exclude completed work, the checkpoint follows a complete business unit, and errors propagate instead of being swallowed. Restart safety and bounded retrieval are separate requirements: a persisted watermark can make retries safe, but an outer `FindSet` over the full remaining tail with periodic commits still retrieves the complete set because [`FindSet` is not implemented as `TOP X`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-al-database-methods-and-performance-on-server#get-find-findset-and-next). ## Best Practice -If the batch is large enough that a single transaction is untenable, use an ordered primary-key watermark and retrieve a bounded next-N key list. `FindSet` is optimized for reading the complete filtered set and isn't implemented as `TOP X`, so calling it over the remaining tail and breaking after N rows does not bound retrieval. The sample uses a query capped by [`TopNumberOfRows`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/query/queryinstance-topnumberofrows-method) to fill a temporary key buffer, then takes update locks and modifies only those exact keys. It does not reconstruct an inclusive first-to-last range that concurrent inserts could expand. Commit after the bounded inner loop returns and persist its last selected key as the next watermark. Use a stable key and define how a later run handles records inserted at or below an already committed watermark. A `Codeunit.Run` boundary can also own a chunk when its implicit commit and error behavior fit the caller — see `codeunit-run-as-atomic-sub-operation.md`. +If the batch is large enough that a single transaction is untenable, use an ordered primary-key watermark and retrieve a bounded next-N key list. The sample uses a query capped by [`TopNumberOfRows`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/query/queryinstance-topnumberofrows-method) to fill a temporary key buffer, then takes update locks and modifies only those exact keys. It does not reconstruct an inclusive first-to-last range that concurrent inserts could expand. Persist the last selected key in the same transaction as the completed chunk, then commit after the bounded helper returns. Use a stable key and define how a later run handles records inserted at or below an already committed watermark. Let errors escape so failed work is not recorded as complete. A `Codeunit.Run` boundary can also own a chunk when its implicit commit and error behavior fit the caller — see `codeunit-run-as-atomic-sub-operation.md`. See sample: `avoid-commit-inside-loops.good.al`. ## Anti Pattern -Placing Commit inside `repeat ... until Next() = 0` is almost always a mistake: it is unusual for the correctness of the operation to depend on per-row commits, and the cost of starting a new transaction on every row dominates the work. A capped query that discovers only an upper key and then re-reads an inclusive key range is not exact batching either; concurrent inserts inside that range can enlarge the checkpoint. +Placing Commit inside `repeat ... until Next() = 0` without persisted progress is almost always a mistake: retries re-enter already committed work, while the cost of starting a transaction on every row dominates the operation. A progress variable held only in memory is not restart-safe. A full-tail `FindSet` with a commit every N rows is not bounded retrieval, even if a persisted watermark makes it restart-safe. A capped query that discovers only an upper key and then re-reads an inclusive key range is not exact batching either; concurrent inserts inside that range can enlarge the checkpoint. See sample: `avoid-commit-inside-loops.bad.al`. diff --git a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md index ae83968..024aae1 100644 --- a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md @@ -15,12 +15,12 @@ application-area: [all] ## Best Practice -Use `ModifyAll` when the loop directly assigns the same value, does not call `Validate`, needs no per-row calculation, and does not depend on `OnModify` unless the equivalent `RunTrigger` value is supplied. Check whether table-extension triggers, event subscribers, global triggers, or media fields force row-by-row fallback (see `triggers-and-media-field-regress-modifyall.md`). +Use `ModifyAll` when the loop directly assigns the same value, does not call `Validate`, needs no per-row calculation, and does not depend on `OnModify` unless the equivalent `RunTrigger` value is supplied. Check whether table trigger code, related subscribers, security filtering, `Media`/`MediaSet`, or companion fields force row-by-row fallback (see `triggers-and-media-field-regress-modifyall.md`). A visible loop for progress UX is acceptable only when evidence shows the equivalent bulk call already executes as individual operations and the loop preserves trigger and business semantics. See sample: `prefer-modifyall-over-per-row-modify.good.al`. ## Anti Pattern -A loop that only assigns a constant and calls `Modify(false)` on a field with no validation side effects. Conversely, replacing `Validate(Field, Value); Modify(true)` with `ModifyAll(Field, Value)` is also an anti-pattern because it silently drops field validation and may drop table-trigger behavior. +A loop that only assigns a constant and calls `Modify(false)` on a field with no validation side effects or bulk fallback condition. A progress dialog alone does not exempt this loop. Conversely, replacing `Validate(Field, Value); Modify(true)` with `ModifyAll(Field, Value)` is also an anti-pattern because it silently drops field validation and may drop table-trigger behavior. See sample: `prefer-modifyall-over-per-row-modify.bad.al`. diff --git a/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md b/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md index c4890a0..dabeb31 100644 --- a/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md +++ b/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: performance -keywords: [modifyall, deleteall, regression, triggers, media, getglobaltabletriggermask, subscriber] +keywords: [modifyall, deleteall, regression, triggers, media, security-filtering, companion-fields, subscriber, progress] technologies: [al] countries: [w1] application-area: [all] @@ -11,12 +11,12 @@ application-area: [all] ## 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. +`ModifyAll` and `DeleteAll` can limit SQL calls, but Microsoft documents that they [revert to individual calls](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-al-database-methods-and-performance-on-server#modifyall-and-deleteall) when the table has trigger code, related modify/delete/global/database event subscribers, active security filtering, `Media` or `MediaSet` fields, or fields added through companion tables. These conditions must be assessed from the target table and runtime context, not only from the visible bulk call. ## 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. +Before introducing a fallback condition, audit the `ModifyAll`/`DeleteAll` call sites that target the table and assess the regression cost. Once a bulk path already executes row by row, one explicit loop can be reasonable when it preserves the same trigger semantics and adds required per-row progress UX; consolidating several regressed bulk calls into one pass can also avoid repeated iteration. This is a narrow equivalence check, not a generic progress-dialog exemption: when no fallback condition applies, retain the bulk API. ## 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. +Adding a fallback condition to a hot table without auditing bulk-write call sites, or replacing a working bulk API with a per-row loop solely to show progress. The mirror anti-pattern is chaining several bulk calls on a table that already falls back, causing repeated row-by-row passes. diff --git a/microsoft/knowledge/style/labels-declared-at-object-scope.bad.al b/microsoft/knowledge/style/labels-declared-at-object-scope.bad.al deleted file mode 100644 index 33c63fb..0000000 --- a/microsoft/knowledge/style/labels-declared-at-object-scope.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50262 "Sample Label Scope Bad" -{ - procedure LookupCustomer(CustomerNo: Code[20]) - var - Customer: Record Customer; - GreetingMsg: Label 'Hello %1', Comment = '%1 = Customer Name'; - begin - if Customer.Get(CustomerNo) then - Message(GreetingMsg, Customer.Name); - end; -} diff --git a/microsoft/knowledge/style/labels-declared-at-object-scope.good.al b/microsoft/knowledge/style/labels-declared-at-object-scope.good.al deleted file mode 100644 index 415bda7..0000000 --- a/microsoft/knowledge/style/labels-declared-at-object-scope.good.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50263 "Sample Label Scope Good" -{ - var - GreetingMsg: Label 'Hello %1', Comment = '%1 = Customer Name'; - - procedure LookupCustomer(CustomerNo: Code[20]) - var - Customer: Record Customer; - begin - if Customer.Get(CustomerNo) then - Message(GreetingMsg, Customer.Name); - end; -} diff --git a/microsoft/knowledge/style/labels-declared-at-object-scope.md b/microsoft/knowledge/style/labels-declared-at-object-scope.md index 24a868e..91d75d7 100644 --- a/microsoft/knowledge/style/labels-declared-at-object-scope.md +++ b/microsoft/knowledge/style/labels-declared-at-object-scope.md @@ -1,30 +1,18 @@ --- bc-version: [all] domain: style -keywords: [label, scope, procedure, translation, localization, xliff] +keywords: [label, scope, procedure, translation, localization, xliff, false-positive] technologies: [al] countries: [w1] application-area: [all] --- -# Declare Labels at object scope, not inside procedure `var` blocks +# Procedure-local Labels are valid ## Description -`Label` is the AL declaration that participates in the translation pipeline: the build extracts every Label declared in an object into the `.xlf` file shipped to translators, and the runtime substitutes the localized value when the object is loaded. Translation tooling discovers Labels by walking the object's top-level declarations. - -Labels declared inside a procedure-local `var` block are still **compiled** as Label values, but their participation in localization is fragile: depending on the BC version, the build pipeline, and the translation toolchain in use, procedure-local Labels may be missed during XLIFF extraction, may be re-emitted with auto-generated keys that change between builds, or may not be addressable by reviewers triaging translations. The reliable, supported pattern is to declare every Label in the object's top-level `var` block. - -The same rule applies to all object types that own behavior: codeunits, pages, tables, reports, queries, and their extensions. For shared messages used by multiple objects, declare the Label in the most appropriate owning object and reference it — do not duplicate the literal across procedure-scoped declarations in several places. +The AL language supports `Label` variables at both object and procedure scope. Microsoft documents the [Label data type](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-using-labels#label-data-type) without imposing an object-scope requirement, and the translation pipeline generates an XLF file containing [all labels used by the extension](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-work-with-translation-files#generating-the-xliff-file). There is no documented correctness or localization defect caused solely by declaring a Label in a procedure-local `var` block. ## Best Practice -Move every `Label` to the object's top-level `var` block. Use the appropriate suffix (`Msg`, `Err`, `Qst`, `Lbl`, `Tok`, `Txt`) on the variable name so reviewers and the translation team can see at a glance what role the string plays. Pair non-translatable strings (URLs, JSON/XML fragments, integration tokens) with `Locked = true`, as covered by `label-locked-for-non-translatable.md`. - -See sample: `labels-declared-at-object-scope.good.al`. - -## Anti Pattern - -Declaring `Label` inside a procedure-local `var` block — `procedure Lookup() var GreetingMsg: Label 'Hello %1';` — couples the translatable string to one procedure, hides it from object-level review, and depends on a translation pipeline behavior that is not part of the AL language contract. - -See sample: `labels-declared-at-object-scope.bad.al`. +Choose object scope when a Label is reused or when an established repository convention prefers central declarations; choose procedure scope when the Label belongs to one procedure. Do not report a correctness or localization finding solely because a Label is local. An explicit object-scope convention is at most a low-severity maintainability preference. This guidance applies equally to production and test apps: test code still needs localization where its strings are user-facing or translator-facing. diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al b/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al index 1ecf47d..e1b8fc7 100644 --- a/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al +++ b/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al @@ -1,43 +1,28 @@ -codeunit 50401 "Test UI Handlers Bad" +codeunit 50401 "Test UI Handler Proof Bad" { Subtype = Test; - // Several wiring mistakes, each of which fails at runtime rather than as a - // clean assertion the reviewer can read: - // * A UI call with no listed handler -> "unhandled UI" abort (the Message - // below has no handler). - // * The mirror mistake, listing a handler the path never hits, instead - // fails with "handler function was not executed". - // * A handler that hardcodes its answer and asserts inline, with no - // enqueue/dequeue -> nothing proves the RIGHT dialog fired the RIGHT - // number of times, and a failed inline assert can be swallowed by the - // calling UI operation. [Test] - [HandlerFunctions('ConfirmHandler')] - procedure PostDocumentConfirmsAndMessages() + [HandlerFunctions('CustomerCardHandler')] + procedure CustomerCardActionSucceeds() + var + Customer: Record Customer; begin - // No Initialize(): a value leaked by an earlier test corrupts this one. - RunPostingThatConfirmsAndMessages(); - // No AssertEmpty(): a missing or extra dialog goes unnoticed. + Customer.Get('10000'); + ActionSucceeded := true; + + Page.RunModal(Page::"Customer Card", Customer); + + // This only proves a value assigned before the action stayed true. + Assert.IsTrue(ActionSucceeded, 'The customer card action failed.'); end; - local procedure RunPostingThatConfirmsAndMessages() + [ModalPageHandler] + procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card") begin - // Raises a Confirm AND a Message, but only ConfirmHandler is listed: - // the Message has nothing to intercept it -> unhandled-UI runtime abort. - if Confirm('Post this document?', false) then - Message('Posting completed.'); - end; - - [ConfirmHandler] - procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean) - begin - // Hardcoded expectation and hardcoded reply. If the wrong dialog fires, - // this inline assert may never surface as the test's verdict. - Assert.AreEqual('Post this document?', Question, 'Wrong confirm.'); - Reply := true; end; var Assert: Codeunit "Library Assert"; + ActionSucceeded: Boolean; } diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.good.al b/microsoft/knowledge/testing/ui-handlers-in-tests.good.al index f955477..42b7471 100644 --- a/microsoft/knowledge/testing/ui-handlers-in-tests.good.al +++ b/microsoft/knowledge/testing/ui-handlers-in-tests.good.al @@ -1,57 +1,28 @@ -codeunit 50400 "Test UI Handlers Good" +codeunit 50400 "Test UI Handler Capture Good" { Subtype = Test; [Test] - [HandlerFunctions('ConfirmHandler,PostMessageHandler')] - procedure PostDocumentConfirmsAndMessages() + [HandlerFunctions('CustomerCardHandler')] + procedure CustomerCardShowsSelectedCustomer() + var + Customer: Record Customer; begin - Initialize(); + Customer.Get('10000'); + CapturedCustomerNo := ''; - // [GIVEN] the test enqueues, in interaction order, what each handler - // will see and how it should answer: the Confirm's expected - // question plus the reply to return, then the expected Message. - LibraryVariableStorage.Enqueue('Post this document?'); // expected question (substring) - LibraryVariableStorage.Enqueue(true); // reply ConfirmHandler returns - LibraryVariableStorage.Enqueue('Posting completed.'); // expected message (substring) + Page.RunModal(Page::"Customer Card", Customer); - // [WHEN] the code under test raises the Confirm and then the Message - RunPostingThatConfirmsAndMessages(); - - // [THEN] every enqueued expectation was consumed exactly once - LibraryVariableStorage.AssertEmpty(); + Assert.AreEqual(Customer."No.", CapturedCustomerNo, 'The customer card opened for the wrong customer.'); end; - local procedure Initialize() + [ModalPageHandler] + procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card") begin - // Clear leftover values so a value leaked by an earlier test cannot - // cascade into this one. - LibraryVariableStorage.Clear(); - end; - - local procedure RunPostingThatConfirmsAndMessages() - begin - // Stands in for the production routine that confirms, then messages. - if Confirm('Post this document?', false) then - Message('Posting completed.'); - end; - - [ConfirmHandler] - procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean) - begin - // Verify the RIGHT dialog fired (substring match), then return the - // reply the test enqueued for it. - Assert.ExpectedConfirm(LibraryVariableStorage.DequeueText(), Question); - Reply := LibraryVariableStorage.DequeueBoolean(); - end; - - [MessageHandler] - procedure PostMessageHandler(Message: Text[1024]) - begin - Assert.ExpectedMessage(LibraryVariableStorage.DequeueText(), Message); + CapturedCustomerNo := CustomerCard."No.".Value(); end; var Assert: Codeunit "Library Assert"; - LibraryVariableStorage: Codeunit "Library - Variable Storage"; + CapturedCustomerNo: Code[20]; } diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.md b/microsoft/knowledge/testing/ui-handlers-in-tests.md index 338e9ec..42a8d83 100644 --- a/microsoft/knowledge/testing/ui-handlers-in-tests.md +++ b/microsoft/knowledge/testing/ui-handlers-in-tests.md @@ -1,28 +1,28 @@ --- bc-version: [all] domain: testing -keywords: [handler, handlerfunctions, confirm, message, strmenu, variable-storage, enqueue, unhandled-ui] +keywords: [handler, handlerfunctions, confirm, message, strmenu, variable-storage, enqueue, capture, runmodal, unhandled-ui] technologies: [al] countries: [w1] application-area: [all] --- -# Wire and verify UI handlers with enqueue-driven expectations +# Wire UI handlers and verify meaningful outcomes ## Description -A test runs headless: there is no interactive user to answer a dialog. Every UI call the executed path raises — `Confirm`, `Message`, error dialogs, `Page.Run`/`RunModal`, `Report.Run`/`RunModal`, request pages, `StrMenu`, `Notification.Send` — must be intercepted by a handler carrying the matching attribute (`[ConfirmHandler]`, `[MessageHandler]`, `[StrMenuHandler]`, `[ModalPageHandler]`, …) and named in the method's `[HandlerFunctions(...)]`. The list is a two-sided contract: raise a UI call with no listed handler and the platform aborts with an *unhandled UI* error; list a handler the path never hits and it fails with *"handler function was not executed"*. Both are runtime failures — the test never reaches its verdict, so a reviewer sees an infrastructure error instead of a result on the behavior under test. +A test runs headless, so every UI call on the executed path must be intercepted by a matching handler named in `[HandlerFunctions(...)]`. The list is a two-sided contract: an unhandled UI call aborts the test, while Microsoft documents that [every listed handler must execute at least once](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/attributes/devenv-handlerfunctions-attribute#remarks) or the test fails. -Getting the handler *present* is only half the job; the handler must also verify the *right* dialog fired the *right* number of times. Do that by driving handlers from the test, not by hardcoding answers inside them. +Beyond that wiring guarantee, the test must verify the behavior it cares about. The appropriate pattern depends on the contract: a handler can capture concrete page state or a result and the test can assert that semantic postcondition after `RunModal`; assertions inside a handler are also supported. Queue/enqueue/dequeue and `LibraryVariableStorage.AssertEmpty` are useful when interaction order, count, text, replies, or a scripted sequence is itself part of the contract, but they are not mandatory for every handler. ## Best Practice -Make the test own the expectations and the handlers consume them. Before acting, the test `Enqueue`s — in interaction order — the expected text (a stable substring) and any reply each handler must return. The handler `Dequeue`s the expected text, verifies it with the purpose-built asserts (`Assert.ExpectedMessage`, `Assert.ExpectedConfirm`, `Assert.ExpectedStrMenu` — which match on a fragment, not the full localized caption), then `Dequeue`s and returns its reply. Finish the test body with `LibraryVariableStorage.AssertEmpty` to prove every enqueued interaction fired exactly once, and start each test with an `Initialize` that calls `LibraryVariableStorage.Clear` so a value leaked by an earlier test cannot cascade. List in `[HandlerFunctions]` precisely the handlers the scenario triggers — no superset "just in case", no subset that happens to work today. +List precisely the handlers the scenario triggers and make each handler contribute meaningful evidence. For a single modal page, reset a capture variable before the action, capture a concrete value from the page in the handler, and assert the expected value after `RunModal`. For ordered or repeated interactions, let the test enqueue expectations, let handlers dequeue and verify them, clear storage during initialization, and finish with `AssertEmpty`. See sample: `ui-handlers-in-tests.good.al`. ## Anti Pattern -Omitting a handler for a UI call the path raises (unhandled-UI abort), padding the list with a handler the path never reaches ("handler function was not executed"), or writing handlers that hardcode their answer and assert inline with no enqueue/dequeue. The last is the subtle one: nothing proves the correct dialog fired the expected number of times, and an inline assertion that fails inside a handler can be swallowed by the calling UI operation, leaving the suite green while the behavior is broken. Skipping `Initialize`/`AssertEmpty` hides both a leaked queue and a missing or extra dialog. +Omitting a handler for a UI call, listing a handler the path never reaches, or claiming action success from a Boolean set before the action runs. A handler that only closes a page can also leave the test without a semantic assertion. Do not flag the absence of queue storage by itself; require it only when the test needs to prove interaction order, count, text, replies, or a scripted sequence. See sample: `ui-handlers-in-tests.bad.al`. diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index de2700c..f624de3 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-review.md @@ -51,7 +51,7 @@ When the post-conflict worklist is empty because no applicable events knowledge The following targeted checks map diff signals to specific `events` articles. Treat each as a candidate-selection cue: when the signal appears in the changed code, add the named article to the worklist and evaluate it in Action. -- `IsHandled` raised without an immediately preceding `IsHandled := false;`, or one `IsHandled` variable reused across several raises with no reset between them — `initialize-ishandled-to-false-before-publishing`. +- An `IsHandled` value that can carry over as `true` (reused after an earlier raise, input/global/field, or otherwise seeded) is passed to another publisher without a reset — `initialize-ishandled-to-false-before-publishing`. Do not match a single raise using a fresh local Boolean, or a later raise reached only after `if IsHandled then exit;`. - `if IsHandled then exit;` in a routine that also raises a paired `OnAfter…` event later, so the after-event is skipped whenever the call is handled — `preserve-onafter-execution-when-ishandled-skips-the-body`. - Any parameter added to a public Business/Integration event procedure, regardless of position; do not flag additions or reordering on `local`/`internal` publishers merely because a new parameter was not appended — `add-new-event-parameters-at-the-end`. - A shipped Business/Integration event renamed or removed, or an existing parameter renamed, removed, retyped, or changed to/from `var`, based on the mistaken assumption that `local` or `internal` prevents dependent subscription; parameter order alone is not a subscriber-contract violation — `treat-local-and-internal-events-as-subscriber-contracts`. diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index 3262179..bf2f4e8 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -47,7 +47,8 @@ Apply these targeted cues even when simple token overlap would rank the article - Worklist `use-setautocalcfields-for-per-row-flowfields.md` when a record loop calls `CalcFields`, or when every row reads the same FlowField for a comparison, branch, or per-record action. Worklist `calcsums-instead-of-calcfields-in-loop.md` instead when the loop only accumulates one set total. - Worklist `hidden-flowfields-still-calculate-before-bc26-opt-in.md` when a page control directly sources a FlowField and sets `Visible = false` or a visibility expression. Suppress it when the target is known to have BC26's **Calculate only visible FlowFields** feature enabled, or when the FlowField is cheap and intentionally preloaded. -- Worklist `avoid-commit-inside-loops.md` only when `Commit()` is inside a record-iteration body or a helper invoked once per row. Do not match one `Commit()` after a bounded checkpoint helper returns, a `Commit()` outside iteration, or comments and documentation that merely mention commits. +- Worklist `avoid-commit-inside-loops.md` when `Commit()` is inside a record-iteration body or a checkpoint loop lacks persisted progress that excludes completed work on retry. Do not match a commit after a complete business unit when the same transaction persists a restart-safe watermark/state and errors propagate. Still match a full-tail `FindSet` with periodic commits as unbounded retrieval; restart safety does not make it `TOP X`. +- Worklist `prefer-modifyall-over-per-row-modify.md` for a constant-assignment `Modify(false)` loop with no validation or per-row semantics. Worklist `triggers-and-media-field-regress-modifyall.md` when table trigger code, related subscribers, security filtering, `Media`/`MediaSet`, or companion fields affect a bulk path. A progress dialog does not generically exempt a loop; accept it only when the equivalent bulk call already falls back to individual operations and semantics are preserved. - Worklist `avoid-cloning-records-before-modify-delete-in-loops.md` when an iteration calls `Copy` or `RecordRef.GetTable` before `Modify`/`Delete`, or passes the iterated record without `var` to a helper that writes that record. Do not worklist it from `Modify`, `Delete`, or `RecordRef` alone; exclude a direct write on the iterator, a read-only copy, a temporary record, a different target table, and a `RecordRef` opened and iterated directly. - Worklist `use-tryfunction-for-error-catching-not-rollback.md` only when writes occur inside a try method and the code or surrounding flow expects an error to roll them back. A bare try-method call whose Boolean result is ignored belongs exclusively to `error-handling/ignored-tryfunction-return-disables-try-semantics.md`; do not worklist the performance article from that call shape alone. - For `LockTable` in a pure read helper, select exactly one owner. Use `do-not-locktable-in-read-only-procedure.md` when the helper needs no stronger isolation and should remove the lock. Use `prefer-readisolation-over-locktable-for-reads.md` instead when the code explicitly requires committed-read semantics and `ReadIsolation` is the replacement. Never emit both findings for the same call. @@ -74,7 +75,7 @@ Set `confidence` to: After evaluating each worklist entry, also consider whether the diff exhibits a performance defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material performance defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly performance; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. -For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index ea95269..0bbd8ae 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -70,7 +70,7 @@ Set `confidence` to: This leaf emits only knowledge-backed privacy findings. Do NOT emit reference-less `agent:` findings in this domain: online evaluation shows the privacy agent-finding channel yields almost no accepted findings and a high volume of dismissed noise, so a privacy concern that no worklist knowledge file covers is omitted here rather than emitted with `references: []`. When you spot a material privacy defect no article covers, the durable fix is to add a knowledge article in BCQuality (per the online-eval self-improvement loop) so this leaf can cite it — not a one-off reference-less finding. Before treating a candidate as uncovered, check the worklist for a knowledge file that matches it; if one exists, emit it as a knowledge-backed finding. See `skills/do.md` for the full contract. -For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index 12956bd..8472afe 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -70,7 +70,7 @@ Set `confidence` to: After evaluating each worklist entry, also consider whether the diff exhibits a security defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material security defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly security; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. -For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index ee4daf5..bffef4d 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -60,7 +60,7 @@ When the post-conflict worklist is empty because no applicable style knowledge e 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`. -Severity calibration — a formal analyzer already flags the mechanical presence/naming conventions (the `this` keyword AA0248, approved label suffixes AA0074, variable-declaration order by type AA0021, a missing `ToolTip`, required parentheses). On those, BCQuality's value is the *explanation* of why the rule exists, not a second gate; emit them at `info` so a consumer that gates on severity does not re-flag what CodeCop/AppSourceCop already reports. Reserve `minor` for style issues with concrete downstream impact the analyzer does not catch — a `Label` declared at procedure-local instead of object scope (no analyzer enforces label scope, and mis-scoped Labels are fragile in the translation pipeline), lost translation or telemetry classification from a string-built `Error`, an `OptionCaption` that does not match its `OptionMembers`, a misleading named invocation. This keeps the domain's default output advisory and prevents analyzer-redundant noise from competing with substantive review. +Severity calibration — a formal analyzer already flags the mechanical presence/naming conventions (the `this` keyword AA0248, approved label suffixes AA0074, variable-declaration order by type AA0021, a missing `ToolTip`, required parentheses). On those, BCQuality's value is the *explanation* of why the rule exists, not a second gate; emit them at `info` so a consumer that gates on severity does not re-flag what CodeCop/AppSourceCop already reports. Reserve `minor` for style issues with concrete downstream impact the analyzer does not catch — lost translation or telemetry classification from a string-built `Error`, an `OptionCaption` that does not match its `OptionMembers`, or a misleading named invocation. A procedure-local `Label` is valid and is not a correctness or localization finding; an explicit repository preference for object scope is at most low-severity maintainability guidance. This keeps the domain's default output advisory and prevents analyzer-redundant noise from competing with substantive review. Set `confidence` to: @@ -70,7 +70,7 @@ Set `confidence` to: After evaluating each worklist entry, also consider whether the diff exhibits a style defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a clear, widely-accepted AL style violation with a concrete basis a knowledgeable BC reviewer would agree on — steelman it first and drop personal preference, speculation, and any single defensible formatting choice among several; when in doubt, omit. The scope is strictly style — naming, labelling, formatting, and analyzer-adjacent conventions. A correctness, logic, data-integrity, or contract defect is NOT a style finding even when it can be reworded as a convention: a method that mutates a shared `Record`'s filters, an unfiltered `DeleteAll`, a violated interface contract, or a wrong boolean guard are behavioural defects, not conventions — do not emit them here under a style framing. If a specific domain leaf covers the concern (performance, security, error-handling, …) it belongs there; if no knowledge file in any domain covers it, it belongs to the `al-code-review` super-skill's cross-cutting self-review agent channel (`from-sub-skill: "agent"`, `severity` capped at `minor`), not to this leaf. A reliable test: if you cannot cite a style `## Best Practice`/`## Anti Pattern` for the concern, it is very likely not a style finding. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. -For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index 2483f12..168b6c8 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-review.md @@ -50,7 +50,7 @@ The following targeted checks cover every current `testing` article. Treat each - A permission-sensitive test uses `TestPermissions = Disabled`, claims to test a restricted user without `"Permissions Mock"`/`"Library - Lower Permissions"`, or declares `[TestPermissions(...)]` without applying that context — `permission-tests-must-lower-the-execution-context`. - Test fixture code manually calls `Init`/`Insert`, invents keys or prerequisite records, or bypasses available `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, or equivalent library codeunits — `use-library-codeunits-for-test-fixtures`. - `asserterror` is added or changed without a following `Assert.ExpectedError`, `Assert.ExpectedErrorCode`, or a purpose-built assertion such as `ExpectedTestFieldError` — `asserterror-needs-expectederror-and-code`. -- A test path raises UI, `[HandlerFunctions(...)]` does not exactly match the invoked handlers, a handler hardcodes replies instead of using enqueue/dequeue expectations, or `LibraryVariableStorage.Clear`/`AssertEmpty` is missing — `ui-handlers-in-tests`. +- A test path raises UI and `[HandlerFunctions(...)]` does not match the invoked handlers, or the test has no meaningful evidence of the UI result (for example, it treats a Boolean set before the action as proof of success) — `ui-handlers-in-tests`. A capture/reset/assert-after-`RunModal` pattern is valid. Enqueue/dequeue and `AssertEmpty` are required only when order, count, text, replies, or a scripted sequence is part of the contract. 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`. @@ -64,6 +64,8 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape. - Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. +For `ui-handlers-in-tests`, use `major` when missing or incorrectly listed handlers make the test fail at runtime. Use `minor` when the test executes but lacks a meaningful semantic postcondition, including a pre-set Boolean used as proof. Do not escalate solely because a handler does not use queue storage or asserts inside the handler. + Set `confidence` to: - `high` when the detection is based on an unambiguous pattern match (attribute, handler declaration, assertion sequence, or fixture call). diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index 81ab12e..c0af5af 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -63,7 +63,7 @@ Set `confidence` to: This leaf emits only knowledge-backed UI and accessibility findings. Do NOT emit reference-less `agent:` findings in this domain: online evaluation shows the UI/accessibility agent-finding channel yields almost no accepted findings and a high volume of dismissed noise, so a UI or accessibility concern that no worklist knowledge file covers is omitted here rather than emitted with `references: []`. When you spot a material UI or accessibility defect no article covers, the durable fix is to add a knowledge article in BCQuality (per the online-eval self-improvement loop) so this leaf can cite it — not a one-off reference-less finding. Before treating a candidate as uncovered, check the worklist for a knowledge file that matches it; if one exists, emit it as a knowledge-backed finding. See `skills/do.md` for the full contract. -For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md index 879e788..667ea32 100644 --- a/microsoft/skills/review/al-upgrade-review.md +++ b/microsoft/skills/review/al-upgrade-review.md @@ -66,7 +66,7 @@ Set `confidence` to: After evaluating each worklist entry, also consider whether the diff exhibits a upgrade defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material upgrade or breaking-change defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly upgrade; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. -For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. From ead337f9cbf237dd07532393afcb690c4ae160b3 Mon Sep 17 00:00:00 2001 From: wenjiefan Date: Tue, 18 Aug 2026 14:09:11 +0200 Subject: [PATCH 45/86] Address review guidance feedback Preserve independent event seams, cover loop-carried handled state, strengthen checkpoint and UI-handler fixtures, and align DeleteAll fallback guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 10646e50-2d8b-4cca-b02b-dfa78629e6a1 --- ...shandled-to-false-before-publishing.bad.al | 19 +++++++++++ ...handled-to-false-before-publishing.good.al | 21 ++++++------ ...ze-ishandled-to-false-before-publishing.md | 6 ++-- .../avoid-commit-inside-loops.bad.al | 14 +++++++- .../avoid-commit-inside-loops.good.al | 6 +++- ...se-deleteall-for-filtered-bulk-deletion.md | 8 ++--- .../testing/ui-handlers-in-tests.bad.al | 34 +++++++++++++++++-- .../testing/ui-handlers-in-tests.good.al | 3 +- microsoft/skills/review/al-events-review.md | 2 +- microsoft/skills/review/al-testing-review.md | 2 +- skills/do.md | 2 +- 11 files changed, 91 insertions(+), 26 deletions(-) diff --git a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.bad.al b/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.bad.al index 2cb465d..7192bc1 100644 --- a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.bad.al +++ b/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.bad.al @@ -17,6 +17,20 @@ codeunit 50241 "IsHandled Init Bad Sample" DiscountPct += 2; end; + procedure ApplyLineDiscounts(var SalesLine: Record "Sales Line") + var + LineIsHandled: Boolean; + begin + if SalesLine.FindSet() then + repeat + // Bug: the local initializes only once. A subscriber that handles + // one line leaves true for every later iteration. + OnBeforeApplyLineDiscount(SalesLine, LineIsHandled); + if not LineIsHandled then + SalesLine.Validate("Line Discount %", 5); + until SalesLine.Next() = 0; + end; + [IntegrationEvent(false, false)] local procedure OnBeforeApplyHeaderDiscount(var SalesHeader: Record "Sales Header"; var DiscountPct: Decimal; var IsHandled: Boolean) begin @@ -26,4 +40,9 @@ codeunit 50241 "IsHandled Init Bad Sample" local procedure OnBeforeApplyPaymentDiscount(var SalesHeader: Record "Sales Header"; var DiscountPct: Decimal; var IsHandled: Boolean) begin end; + + [IntegrationEvent(false, false)] + local procedure OnBeforeApplyLineDiscount(var SalesLine: Record "Sales Line"; var IsHandled: Boolean) + begin + end; } diff --git a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.good.al b/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.good.al index a595e8a..f3e57a3 100644 --- a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.good.al +++ b/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.good.al @@ -4,19 +4,18 @@ codeunit 50240 "IsHandled Init Good Sample" procedure ApplyDiscounts(var SalesHeader: Record "Sales Header") var DiscountPct: Decimal; - IsHandled: Boolean; + HeaderIsHandled: Boolean; + PaymentIsHandled: Boolean; begin - // A freshly declared local Boolean is false. - OnBeforeApplyHeaderDiscount(SalesHeader, DiscountPct, IsHandled); - if IsHandled then - exit; - DiscountPct := 5; + // Each fresh local is false and belongs to one non-looping raise. + OnBeforeApplyHeaderDiscount(SalesHeader, DiscountPct, HeaderIsHandled); + if not HeaderIsHandled then + DiscountPct := 5; - // Reaching this point proves that IsHandled is still false. - OnBeforeApplyPaymentDiscount(SalesHeader, DiscountPct, IsHandled); - if IsHandled then - exit; - DiscountPct += 2; + // Handling the header event does not suppress this independent seam. + OnBeforeApplyPaymentDiscount(SalesHeader, DiscountPct, PaymentIsHandled); + if not PaymentIsHandled then + DiscountPct += 2; end; [IntegrationEvent(false, false)] diff --git a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.md b/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.md index 9a2aef2..12eb39c 100644 --- a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.md +++ b/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.md @@ -11,16 +11,16 @@ application-area: [all] ## Description -A routine that raises an `OnBefore…` integration event with a `var IsHandled: Boolean` parameter passes that variable by reference, so a pre-existing `true` can affect the following control flow. AL [automatically initializes Boolean variables to `false`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-al-variables#initialization), so a freshly declared local Boolean passed to one event is already deterministic. The same is true when control flow proves the variable is `false`; for example, reaching a second raise after `if IsHandled then exit;` proves that the first raise did not leave it `true`. +A routine that raises an `OnBefore…` integration event with a `var IsHandled: Boolean` parameter passes that variable by reference, so a pre-existing `true` can affect the following control flow. AL [automatically initializes Boolean variables to `false`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-al-variables#initialization), so a freshly declared local Boolean passed to one event exactly once per procedure invocation is already deterministic. Initialization does not repeat for each loop iteration: a local declared outside a loop can carry `true` from one iteration to the next even when the source contains only one textual event raise. Outside a loop, reaching a later raise after `if IsHandled then exit;` also proves the value is `false`, provided that early exit is semantically correct and does not skip required downstream events. ## Best Practice -Reset `IsHandled := false;` before a raise only when the value might otherwise carry over as `true`: the same variable is reused after an earlier raise without a control-flow proof that it is false, the value comes from an input parameter, field, or global, or earlier code seeds it. A reset on a guaranteed-false fresh local can be retained for readability, but its absence is not a correctness finding. +Reset `IsHandled := false;` before a raise only when the value might otherwise carry over as `true`: the same variable is reused after an earlier raise without a control-flow proof that it is false, a raise is re-entered by a loop, the value comes from an input parameter, field, or global, or earlier code seeds it. Prefer separate fresh locals when independent event seams need independent handled state. A reset on a guaranteed-false fresh local used by one non-looping raise, or before a later raise reached only after a semantically valid `if IsHandled then exit;`, can be retained for readability, but its absence is not a correctness finding. See sample: `initialize-ishandled-to-false-before-publishing.good.al`. ## Anti Pattern -Raising `OnBeforeX(…, IsHandled)` when the variable can still be `true` from an earlier raise or another source, so the new publisher call starts with stale state. Do not match a single raise using a fresh local Boolean, or a later raise reached only after `if IsHandled then exit;`. +Raising `OnBeforeX(…, IsHandled)` when the variable can still be `true` from an earlier raise, an earlier loop iteration, or another source, so the publisher call starts with stale state. Do not match a single non-looping raise using a fresh local Boolean, or a later raise reached only after a semantically valid `if IsHandled then exit;` proves the value is false. See sample: `initialize-ishandled-to-false-before-publishing.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al b/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al index feacfcc..696b671 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al @@ -3,12 +3,24 @@ codeunit 50129 "Perf Sample CommitInLoop Bad" procedure NormalizeCustomerNames() var Customer: Record Customer; + LastCustomerNo: Code[20]; + ProcessedCount: Integer; begin + Customer.SetFilter("No.", '>%1', LastCustomerNo); if Customer.FindSet(true) then repeat Customer.Name := UpperCase(Customer.Name); Customer.Modify(); - Commit(); + + // LastCustomerNo exists only in memory, so a retry cannot exclude + // work that was already committed. + LastCustomerNo := Customer."No."; + ProcessedCount += 1; + + // This still opened a FindSet over the complete remaining tail; + // periodic commits do not turn retrieval into bounded TOP X. + if ProcessedCount mod 500 = 0 then + Commit(); 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 index e82f98b..2eb5bd0 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al @@ -19,7 +19,11 @@ codeunit 50128 "Perf Sample CommitInLoop Good" NormalizeState: Record "Perf Normalize State"; LastCustomerNo: Code[20]; begin - NormalizeState.Get('CUSTOMER'); + if not NormalizeState.Get('CUSTOMER') then begin + NormalizeState.Init(); + NormalizeState.Code := 'CUSTOMER'; + NormalizeState.Insert(); + end; LastCustomerNo := NormalizeState."Last Customer No."; while NormalizeNextChunk(LastCustomerNo) do begin diff --git a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md index 1c80835..41ad41f 100644 --- a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md +++ b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: performance -keywords: [deleteall, bulk-delete, sql, ondelete, trigger-bypass] +keywords: [deleteall, bulk-delete, sql, ondelete, trigger-bypass, security-filtering, media, companion-fields] technologies: [al] countries: [w1] application-area: [all] @@ -13,16 +13,16 @@ application-area: [all] ## Description -`DeleteAll(false)` is eligible for a set-based SQL delete with the record variable's filters applied. It is not guaranteed to stay one statement. The base table `OnDelete` trigger is skipped, but table-extension `OnBeforeDelete` and `OnAfterDelete` triggers still run. Extension event subscribers, global delete triggers, and media fields can also require row processing. `DeleteAll(true)` runs the base table `OnDelete` trigger as well and has no performance advantage over `Delete(true)` in a loop. +`DeleteAll(false)` is eligible for a set-based SQL delete with the record variable's filters applied, but it is not guaranteed to stay one statement. Microsoft documents that `DeleteAll` [reverts to individual calls](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-al-database-methods-and-performance-on-server#modifyall-and-deleteall) when the table has trigger code, related delete/global/database event subscribers, active security filtering, `Media` or `MediaSet` fields, or fields added through companion tables. Setting `RunTrigger` to false skips the base table `OnDelete` trigger, but [table-extension `OnBeforeDelete` and `OnAfterDelete` triggers still run](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-deleteall-method#remarks). ## Best Practice -Use filtered `DeleteAll(false)` for purpose-built staging or cleanup tables only after verifying that base-table `OnDelete` logic is unnecessary and installed extensions, subscribers, global triggers, and media fields do not add required per-row behavior or regress the bulk path. If deletion requires per-row business logic, keep an explicit triggered operation instead of simulating trigger execution separately. +Use filtered `DeleteAll(false)` for purpose-built staging or cleanup tables only after verifying that base-table `OnDelete` logic is unnecessary and that trigger code, related subscribers, security filtering, media fields, and companion fields do not add required per-row behavior or regress the bulk path. If deletion requires per-row business logic, keep an explicit triggered operation instead of simulating trigger execution separately. See sample: `use-deleteall-for-filtered-bulk-deletion.good.al`. ## Anti Pattern -Iterating with `FindSet` + `Delete(false)` to clear a filtered staging batch that has no delete logic. The reverse mistake is assuming `DeleteAll` is always one SQL statement without checking table extensions and subscribers. +Iterating with `FindSet` + `Delete(false)` to clear a filtered staging batch that has no delete logic or fallback condition. The reverse mistake is assuming `DeleteAll` is always one SQL statement without checking the documented fallback conditions. See sample: `use-deleteall-for-filtered-bulk-deletion.bad.al`. diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al b/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al index e1b8fc7..d0832fe 100644 --- a/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al +++ b/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al @@ -4,11 +4,11 @@ codeunit 50401 "Test UI Handler Proof Bad" [Test] [HandlerFunctions('CustomerCardHandler')] - procedure CustomerCardActionSucceeds() + procedure PreSetBooleanDoesNotProveCustomerCardResult() var Customer: Record Customer; begin - Customer.Get('10000'); + LibrarySales.CreateCustomer(Customer); ActionSucceeded := true; Page.RunModal(Page::"Customer Card", Customer); @@ -17,12 +17,42 @@ codeunit 50401 "Test UI Handler Proof Bad" Assert.IsTrue(ActionSucceeded, 'The customer card action failed.'); end; + [Test] + [HandlerFunctions('CustomerCardHandler')] + procedure MissingMessageHandlerFailsAtRuntime() + var + Customer: Record Customer; + begin + LibrarySales.CreateCustomer(Customer); + + Page.RunModal(Page::"Customer Card", Customer); + Message('Customer card closed.'); + end; + + [Test] + [HandlerFunctions('CustomerCardHandler,UnusedConfirmHandler')] + procedure UnreachedListedHandlerFailsAtRuntime() + var + Customer: Record Customer; + begin + LibrarySales.CreateCustomer(Customer); + + Page.RunModal(Page::"Customer Card", Customer); + end; + [ModalPageHandler] procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card") begin end; + [ConfirmHandler] + procedure UnusedConfirmHandler(Question: Text[1024]; var Reply: Boolean) + begin + Reply := true; + end; + var Assert: Codeunit "Library Assert"; + LibrarySales: Codeunit "Library - Sales"; ActionSucceeded: Boolean; } diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.good.al b/microsoft/knowledge/testing/ui-handlers-in-tests.good.al index 42b7471..fafc515 100644 --- a/microsoft/knowledge/testing/ui-handlers-in-tests.good.al +++ b/microsoft/knowledge/testing/ui-handlers-in-tests.good.al @@ -8,7 +8,7 @@ codeunit 50400 "Test UI Handler Capture Good" var Customer: Record Customer; begin - Customer.Get('10000'); + LibrarySales.CreateCustomer(Customer); CapturedCustomerNo := ''; Page.RunModal(Page::"Customer Card", Customer); @@ -24,5 +24,6 @@ codeunit 50400 "Test UI Handler Capture Good" var Assert: Codeunit "Library Assert"; + LibrarySales: Codeunit "Library - Sales"; CapturedCustomerNo: Code[20]; } diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index f624de3..ebd2976 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-review.md @@ -51,7 +51,7 @@ When the post-conflict worklist is empty because no applicable events knowledge The following targeted checks map diff signals to specific `events` articles. Treat each as a candidate-selection cue: when the signal appears in the changed code, add the named article to the worklist and evaluate it in Action. -- An `IsHandled` value that can carry over as `true` (reused after an earlier raise, input/global/field, or otherwise seeded) is passed to another publisher without a reset — `initialize-ishandled-to-false-before-publishing`. Do not match a single raise using a fresh local Boolean, or a later raise reached only after `if IsHandled then exit;`. +- An `IsHandled` value that can carry over as `true` (reused after an earlier raise, re-entered on a later loop iteration, input/global/field, or otherwise seeded) is passed to a publisher without a reset — `initialize-ishandled-to-false-before-publishing`. Do not match one non-looping raise using a fresh local Boolean, or a later raise reached only after a semantically valid `if IsHandled then exit;` proves the value is false. - `if IsHandled then exit;` in a routine that also raises a paired `OnAfter…` event later, so the after-event is skipped whenever the call is handled — `preserve-onafter-execution-when-ishandled-skips-the-body`. - Any parameter added to a public Business/Integration event procedure, regardless of position; do not flag additions or reordering on `local`/`internal` publishers merely because a new parameter was not appended — `add-new-event-parameters-at-the-end`. - A shipped Business/Integration event renamed or removed, or an existing parameter renamed, removed, retyped, or changed to/from `var`, based on the mistaken assumption that `local` or `internal` prevents dependent subscription; parameter order alone is not a subscriber-contract violation — `treat-local-and-internal-events-as-subscriber-contracts`. diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index 168b6c8..fb5d50f 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-review.md @@ -74,7 +74,7 @@ Set `confidence` to: After evaluating each worklist entry, also consider whether the diff exhibits a testing defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material testing defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly AL testing; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. -For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add the matching `ExpectedError` assertion after `asserterror`; add or remove a handler name in `HandlerFunctions`; add `LibraryVariableStorage.Clear` or `AssertEmpty`; or replace hand-rolled fixture creation with an evident library call). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add the matching `ExpectedError` assertion after `asserterror`; add or remove a handler name in `HandlerFunctions`; add `LibraryVariableStorage.Clear` or `AssertEmpty` when queue/LVS intentionally verifies interaction order, count, text, replies, or a scripted sequence; or replace hand-rolled fixture creation with an evident library call). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. diff --git a/skills/do.md b/skills/do.md index 23bfb4a..a79c5fc 100644 --- a/skills/do.md +++ b/skills/do.md @@ -220,7 +220,7 @@ A review super-skill MUST preserve `domain` verbatim when rolling a leaf finding **`findings[].suggested-code`** — optional in the schema but **expected for mechanical findings**. It is a concrete code-replacement payload for the lines indicated by `location`. When present, the string MUST be a literal replacement for the source lines covered by `location.line` (or `location.range` if set) — i.e., what the file would contain after the fix, with no surrounding diff markers, fences, or commentary. Consumers MAY render it as a one-click suggestion in the delivery surface (for example, a GitHub ```` ```suggestion ```` block). -Emit `suggested-code` whenever the fix is small, local, and mechanical: deleting unreachable code; replacing one expression (`Count() > 0` → `not IsEmpty()`); moving a local `Label` to object scope; adding a missing property such as `ToolTip`, `OptionCaption`, or `DataClassification`; replacing a string-concatenated `Error` with a Label-backed call; changing a permission token; or adding a missing `else`/guard branch whose replacement is unambiguous from the surrounding diff. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, prefer adapting the `.good.al` replacement into `suggested-code`. +Emit `suggested-code` whenever the fix is small, local, and mechanical: deleting unreachable code; replacing one expression (`Count() > 0` → `not IsEmpty()`); adding a missing property such as `ToolTip`, `OptionCaption`, or `DataClassification`; replacing a string-concatenated `Error` with a Label-backed call; changing a permission token; or adding a missing `else`/guard branch whose replacement is unambiguous from the surrounding diff. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, prefer adapting the `.good.al` replacement into `suggested-code`. Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but `suggested-code` is omitted, set `findings[].suggested-code-omission-reason` to a short explanation (for example, `requires choosing a real event id` or `fix spans multiple non-contiguous locations`). The `suggested-code` payload supplements `message`; it does not replace the explanation in `message`. From 6e321d3c56d7bd24773f92aa0bd56a2e0428108c Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Sat, 22 Aug 2026 12:04:00 +0200 Subject: [PATCH 46/86] Correct severe misconception about SetCurrentKey in the knowledge base --- .../apply-filters-before-iterating.md | 2 +- ...currentkey-aligns-key-with-filters.good.al | 15 -------- .../setcurrentkey-aligns-key-with-filters.md | 24 ------------- ...tkey-sets-sort-order-not-index-hint.bad.al | 20 +++++++++++ ...key-sets-sort-order-not-index-hint.good.al | 18 ++++++++++ ...rrentkey-sets-sort-order-not-index-hint.md | 35 +++++++++++++++++++ 6 files changed, 74 insertions(+), 40 deletions(-) delete mode 100644 microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al delete mode 100644 microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md create mode 100644 microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.bad.al create mode 100644 microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.good.al create mode 100644 microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md diff --git a/microsoft/knowledge/performance/apply-filters-before-iterating.md b/microsoft/knowledge/performance/apply-filters-before-iterating.md index 5f54c3b..76d78ec 100644 --- a/microsoft/knowledge/performance/apply-filters-before-iterating.md +++ b/microsoft/knowledge/performance/apply-filters-before-iterating.md @@ -15,7 +15,7 @@ A `SetRange` or `SetFilter` placed before `FindSet` narrows the result set at th ## 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. +Move every predicate that can be expressed as an equality or range filter into a `SetRange` or `SetFilter` ahead of the find. Make sure a key (index) exists whose leading fields cover the filter so the optimizer can seek; note that `SetCurrentKey` only sets sort order and is not an index hint (see `setcurrentkey-sets-sort-order-not-index-hint.md`). The loop body should then contain only the work that depends on per-row state. See sample: `apply-filters-before-iterating.good.al`. diff --git a/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al b/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al deleted file mode 100644 index 4ab3b0a..0000000 --- a/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index f7f8180..0000000 --- a/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -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/setcurrentkey-sets-sort-order-not-index-hint.bad.al b/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.bad.al new file mode 100644 index 0000000..776994a --- /dev/null +++ b/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.bad.al @@ -0,0 +1,20 @@ +codeunit 50231 "Perf Sample SetCurrentKey Bad" +{ + // Misconception: SetCurrentKey does NOT tell SQL Server to use this index. + // The optimizer picks the index from the filters (WHERE clause) and statistics. + // The result order is never used here, so SetCurrentKey only adds an ORDER BY + // the query does not need — and can push the plan toward a sort. + procedure SumRemainingAmount(CustomerNo: Code[20]) Total: Decimal + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetCurrentKey("Customer No.", Open, "Posting Date"); + CustLedgerEntry.SetRange("Customer No.", CustomerNo); + CustLedgerEntry.SetRange(Open, true); + CustLedgerEntry.SetAutoCalcFields("Remaining Amount"); + if CustLedgerEntry.FindSet() then + repeat + Total += CustLedgerEntry."Remaining Amount"; + until CustLedgerEntry.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.good.al b/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.good.al new file mode 100644 index 0000000..a4fc43f --- /dev/null +++ b/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.good.al @@ -0,0 +1,18 @@ +codeunit 50230 "Perf Sample SetCurrentKey Good" +{ + // SetCurrentKey is used because the rows must be processed oldest-first. + // The sort is a functional requirement, so the ORDER BY it adds is justified. + procedure ApplyOldestEntriesFirst(CustomerNo: Code[20]) + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetRange("Customer No.", CustomerNo); + CustLedgerEntry.SetRange(Open, true); + CustLedgerEntry.SetCurrentKey("Posting Date"); + if CustLedgerEntry.FindSet() then + repeat + // Apply entries in posting-date order ... + until CustLedgerEntry.Next() = 0; + end; +} + diff --git a/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md b/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md new file mode 100644 index 0000000..40428ca --- /dev/null +++ b/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md @@ -0,0 +1,35 @@ +--- +bc-version: [all] +domain: performance +keywords: [setcurrentkey, sort, order-by, index, key, query-optimizer, hint] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# SetCurrentKey only sets sort order — it is not an index hint + +## Description + +A common misconception is that `SetCurrentKey` tells SQL Server which index to use for a query. It does not. In Business Central, `SetCurrentKey` only changes the `ORDER BY` clause of the generated SQL statement. It does not add an index hint, and the SQL Server query optimizer is free to ignore the named key entirely. + +The optimizer picks the index from the `WHERE` clause (your `SetRange`/`SetFilter`) together with table statistics and estimated cost. In practice it almost never chooses an index just because that key appears in `ORDER BY`. So calling `SetCurrentKey` to "steer" the plan toward an index is a no-op for index selection — and can make things worse: an `ORDER BY` that the query does not otherwise need can push the optimizer toward a less selective index or add a Sort operator to the plan. + +Selectivity comes from having the right index available (a key on the table whose leading fields cover the filter) and from filtering on those fields — not from `SetCurrentKey`. + +## Best Practice + +Decide `SetCurrentKey` on one question only: **do I need the result set in a specific order?** + +- If yes — you iterate rows in a defined sequence, or rely on `FindFirst`/`FindLast`/`Next` returning a particular row — call `SetCurrentKey` for that sort. The order is a functional requirement, and the `ORDER BY` is justified. +- If no — omit `SetCurrentKey`. Let the optimizer choose the cheapest plan for your filters; it may pick a better index and skip a sort. + +To make a filtered read fast, ensure a key (index) exists on the table whose leading fields cover the filter, and filter on those fields with `SetRange`/`SetFilter`. That is what lets the optimizer seek. Defining the key creates the index; `SetCurrentKey` is not required to make the optimizer use it. + +See sample: `setcurrentkey-sets-sort-order-not-index-hint.good.al`. + +## Anti Pattern + +Adding `SetCurrentKey` to a filtered read purely in the belief that it forces SQL Server to seek a particular index, when the code never uses the resulting order. This does nothing for index selection and only appends an `ORDER BY` the query does not need, risking an unnecessary sort. Remove the `SetCurrentKey`; rely on the filters and an existing covering key instead. + +See sample: `setcurrentkey-sets-sort-order-not-index-hint.bad.al`. From 9fab60153f868536e63c9d62626293197288ac94 Mon Sep 17 00:00:00 2001 From: Yahya Touil <60827484+yahyatouil-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:54:37 +0100 Subject: [PATCH 47/86] Add TransferFields SkipFieldsNotMatchingType guidance (#133) * Add TransferFields SkipFieldsNotMatchingType guidance * Update transferfields-skip-type-mismatch-can-drop-data.md * Update transferfields-skip-type-mismatch-can-drop-data.good.al * Move good sample reference under Best Practice Aligns the article with the repo convention used by the sibling data-modeling files: the .good.al reference belongs under Best Practice and the .bad.al reference under Anti Pattern. Previously both pointers sat under Anti Pattern, leaving the good-sample reference orphaned in the wrong section. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3c998c71-f30b-40f3-b714-87fafed505d8 --------- Co-authored-by: Jesper Schulz Copilot-Session: 3c998c71-f30b-40f3-b714-87fafed505d8 --- ...ds-skip-type-mismatch-can-drop-data.bad.al | 55 +++++++++++++++++ ...s-skip-type-mismatch-can-drop-data.good.al | 59 +++++++++++++++++++ ...fields-skip-type-mismatch-can-drop-data.md | 26 ++++++++ 3 files changed, 140 insertions(+) create mode 100644 community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.bad.al create mode 100644 community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.good.al create mode 100644 community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md diff --git a/community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.bad.al b/community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.bad.al new file mode 100644 index 0000000..691dfd2 --- /dev/null +++ b/community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.bad.al @@ -0,0 +1,55 @@ +table 50123 "Transfer Source Bad" +{ + fields + { + field(1; "Entry No."; Integer) + { + DataClassification = CustomerContent; + } + field(2; "Reference"; Code[20]) + { + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } + +} + +table 50124 "Transfer Target Bad" +{ + fields + { + field(1; "Entry No."; Integer) + { + DataClassification = CustomerContent; + } + field(2; "Reference"; Integer) + { + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } + +} + +codeunit 50492 "TransferFields Bad" +{ + procedure CopyData(Source: Record "Transfer Source Bad"; var Target: Record "Transfer Target Bad") + begin + Target.TransferFields(Source, true, true); + end; +} \ No newline at end of file diff --git a/community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.good.al b/community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.good.al new file mode 100644 index 0000000..a0ac2a8 --- /dev/null +++ b/community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.good.al @@ -0,0 +1,59 @@ +table 50121 "Transfer Source" +{ + fields + { + field(1; "Entry No."; Integer) + { + DataClassification = CustomerContent; + } + field(2; "Reference"; Code[20]) + { + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } + +} + +table 50122 "Transfer Target" +{ + fields + { + field(1; "Entry No."; Integer) + { + DataClassification = CustomerContent; + } + field(2; "Reference"; Integer) + { + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } + +} + +codeunit 50491 "TransferFields Good" +{ + procedure CopyData(Source: Record "Transfer Source"; var Target: Record "Transfer Target") + var + ConvertedReference: Integer; + begin + Target."Entry No." := Source."Entry No."; + Evaluate(ConvertedReference, Source."Reference"); + Target.Validate("Reference", ConvertedReference); + end; +} diff --git a/community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md b/community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md new file mode 100644 index 0000000..7d1ea77 --- /dev/null +++ b/community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md @@ -0,0 +1,26 @@ +--- +bc-version: [16..] +domain: data-modeling +keywords: [transferfields, skipfieldsnotmatchingtype, type-mismatch, field-mapping, data-transfer] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not use SkipFieldsNotMatchingType to hide required TransferFields mismatches + +## Description + +`Record.TransferFields` copies values between fields with matching field numbers. Without `SkipFieldsNotMatchingType` (or with it `false`), a type mismatch between two fields in the same extension raises a runtime error at the point of transfer. Setting `SkipFieldsNotMatchingType` to `true` removes that error: the field is skipped instead, and the rest of the transfer completes normally. The caller gets no indication that a field was not copied. + +## Best Practice + +Use `TransferFields(Source)` only when every field the destination requires, including primary key fields, is guaranteed to share a matching field number and type with the source; this form defaults `InitPrimaryKeyFields` to `true`. Fields with no matching field number, and fields whose types differ across extensions, are skipped regardless of `SkipFieldsNotMatchingType` — that parameter only governs same-extension type mismatches. If the destination depends on a field that falls into either case, map and validate it explicitly in code rather than relying on `TransferFields` to catch the gap. Use `SkipFieldsNotMatchingType = true` only when skipping same-extension type mismatches is an intentional, documented part of the transfer contract. + +See sample: `transferfields-skip-type-mismatch-can-drop-data.good.al`. + +## Anti Pattern + +Using `TransferFields(Source, InitPrimaryKeyFields, true)` as a generic way to make two evolving table schemas transfer without errors, when the destination depends on every required source field being copied. A type change on either table can turn a previously transferred field into a silently skipped one without making the transfer itself fail. + +See sample: `transferfields-skip-type-mismatch-can-drop-data.bad.al`. \ No newline at end of file From 293f9b13e7d132f110bddffb48540c837265c762 Mon Sep 17 00:00:00 2001 From: Stefano Demiliani <33155438+demiliani@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:04:09 +0200 Subject: [PATCH 48/86] knowledge(performance): add community rules for performance (#134) * knowledge(performance): add community rules for JIT, locks, and false positives These articles capture BC-specific mechanics agents still invert: partial-record JIT on writes, Reset clearing SetLoadFields, HttpClient inside write transactions, and batched number series, plus negative guidance that stops over-eager Query and IsEmpty "fixes". Co-authored-by: Cursor * fix(community/performance): address PR #134 review comments Fixes technical inaccuracies and behavioral issues raised during review of the community performance knowledge articles. avoid-currpage-update-in-onaftergetrecord.md - Removed OnAfterGetCurrRecord from the Best Practice section. That trigger is itself implicitly re-entered on every refresh, so placing CurrPage.Update(false) inside it can re-trigger the very loop the rule warns about. Only OnAction remains as the recommended location. batch-number-series-instead-of-getnextno-per-row.md - Scoped the lock/contention claim to gapless (Normal) series only. Added explicit statement that Allow Gaps series use NumberSequence sequences and do not hold the series-line lock, so the anti-pattern detection signal now excludes Allow Gaps series. countapprox-for-progress-not-count.bad.al / .good.al - Changed FindSet(true) to FindSet() in both samples. The loop is read-only; UpdLock is not needed and was misleading. countapprox-for-progress-not-count.md - Qualified the Count() cost claim: it is expensive only when no SIFT key covers all filtered fields (forcing a SELECT COUNT(*)); a filtered count with matching SIFT coverage is cheap. Added a parenthetical noting that SIFT coverage cannot be assumed for arbitrary filters. dataaccessintent-readonly-on-analytical-objects.md - Changed bc-version from [all] to ["16.."]. DataAccessIntent was introduced at runtime 5.0 / BC 16 and has no effect in earlier versions. - Added precision to the supported objects: pages must be PageType=API with Editable=false; for queries, replica routing only applies when the query is exposed via OData/API, not for AL-to-AL calls. httpclient-inside-write-transaction-holds-locks.good.al - Replaced the Commit()-before-HttpClient pattern with a two-codeunit task-deferral pattern. The write completes inside the caller's transaction (locks released naturally when it ends); a TaskScheduler task runs the HTTP call in a separate session where no write- transaction lock is held. httpclient-inside-write-transaction-holds-locks.md - Changed Best Practice to recommend TaskScheduler/job queue deferral as the primary remedy. - Added an explicit warning against Commit() as a generic remedy: it irrevocably commits all prior writes in the current transaction, so a subsequent failure cannot roll them back. Commit() is appropriate only at top-level entry points where partial persistence is intentional. oncompanyopen-subscribers-must-not-do-io.good.al - Added a ClientType guard so the subscriber exits immediately in background task sessions (OnAfterLogin fires there too, which would create an unbounded task chain without the guard). - Added a TaskScheduler.TaskExists idempotency check to avoid queuing duplicate tasks on repeated logins. - Fixed the error-fallback codeunit in CreateTask from a self-reference to 0 (no error codeunit). - Added a 60-second delay (CurrentDateTime() + 60000) so the task does not compete with the login session itself. prefer-related-table-over-extension-on-hot-ledgers.md - Changed bc-version from [all] to ["23.."]. The companion-table join optimisation (single join per base table, automatic exclusion on List/ OData pages with partial records) was introduced in v23. - Scoped the "join is always paid" claim: since v23 the join is excluded on List/ListPart/OData pages when no extension field is loaded under partial-record semantics, but it is still paid on every posting path and any AL code that accesses an extension field. skip-setloadfields-on-write-and-transferfields.bad.al / .good.al / .md - Changed the example scenario from Modify(false) (which is actually valid with a partial record) to TransferFields+Insert into a temporary record, which is a documented full-load operation. - Removed Modify from the list of operations that force a full load. - Added an explicit note in the .md that Modify itself is not in the full-load list; SetLoadFields is safe to use before Modify(false). use-dedicated-lookup-pages-not-full-lists.bad.al - Added a separate CardPart page definition (50101) to replace the self-referencing FactBox part that referenced the same list page it was embedded in. A part cannot refer to its own container page. validate-on-partial-record-forces-jit.good.al - Restored SetLoadFields to the good sample so it exercises a partial record and the contrast with .bad.al is field selection, not the absence of the feature. The good sample loads both Name and Search Name (the field Name.OnValidate writes), while the bad sample loads only Name, causing a JIT reload of Search Name on every Validate call. * fix(community/performance): correct httpclient and oncompanyopen good samples httpclient-inside-write-transaction-holds-locks.good.al - Pass Customer.RecordId as the last argument to CreateTask so the task is bound to the single customer that was written, not to all customers. - Declare TableNo = Customer on the task codeunit so the platform loads the bound record into Rec automatically when OnRun executes. - Replace the FindSet loop over all customers with Rec."No.", preserving the one-customer scope of the original SyncCustomerLastName procedure. oncompanyopen-subscribers-must-not-do-io.good.al - Replace Session.GetCurrentClientType() with Session.CurrentClientType(), the correct platform method name. - Fix the idempotency check: TaskScheduler.TaskExists() requires a Guid, not a codeunit integer ID. Store the Guid returned by CreateTask in IsolatedStorage (DataScope::Company) under a fixed key; on the next login read it back as Text, Evaluate it to Guid, and pass that Guid to TaskExists so the type matches the method signature. * fix(community/performance): address review feedback --------- Co-authored-by: Cursor --- ...currpage-update-in-onaftergetrecord.bad.al | 24 +++++++ ...urrpage-update-in-onaftergetrecord.good.al | 26 ++++++++ ...oid-currpage-update-in-onaftergetrecord.md | 28 +++++++++ ...series-instead-of-getnextno-per-row.bad.al | 20 ++++++ ...eries-instead-of-getnextno-per-row.good.al | 20 ++++++ ...ber-series-instead-of-getnextno-per-row.md | 28 +++++++++ .../changecompany-in-loop-drops-caches.bad.al | 20 ++++++ ...changecompany-in-loop-drops-caches.good.al | 19 ++++++ .../changecompany-in-loop-drops-caches.md | 28 +++++++++ ...tent-readonly-on-analytical-objects.bad.al | 15 +++++ ...ent-readonly-on-analytical-objects.good.al | 15 +++++ ...ssintent-readonly-on-analytical-objects.md | 28 +++++++++ ...llowed-guard-on-pages-used-as-odata.bad.al | 34 ++++++++++ ...lowed-guard-on-pages-used-as-odata.good.al | 35 +++++++++++ ...guiallowed-guard-on-pages-used-as-odata.md | 28 +++++++++ ...nside-write-transaction-holds-locks.bad.al | 13 ++++ ...side-write-transaction-holds-locks.good.al | 62 +++++++++++++++++++ ...nt-inside-write-transaction-holds-locks.md | 30 +++++++++ ...-before-findset-is-extra-round-trip.bad.al | 16 +++++ ...before-findset-is-extra-round-trip.good.al | 14 +++++ ...mpty-before-findset-is-extra-round-trip.md | 28 +++++++++ ...panyopen-subscribers-must-not-do-io.bad.al | 15 +++++ ...anyopen-subscribers-must-not-do-io.good.al | 30 +++++++++ ...ncompanyopen-subscribers-must-not-do-io.md | 28 +++++++++ ...background-tasks-for-expensive-cues.bad.al | 31 ++++++++++ ...ackground-tasks-for-expensive-cues.good.al | 49 +++++++++++++++ ...age-background-tasks-for-expensive-cues.md | 28 +++++++++ ...to-preserve-partial-load-enumerator.bad.al | 20 ++++++ ...o-preserve-partial-load-enumerator.good.al | 21 +++++++ ...ord-to-preserve-partial-load-enumerator.md | 28 +++++++++ ...table-over-extension-on-hot-ledgers.bad.al | 9 +++ ...able-over-extension-on-hot-ledgers.good.al | 19 ++++++ ...ted-table-over-extension-on-hot-ledgers.md | 29 +++++++++ ...ry-results-bypass-primary-key-cache.bad.al | 28 +++++++++ ...y-results-bypass-primary-key-cache.good.al | 12 ++++ .../query-results-bypass-primary-key-cache.md | 28 +++++++++ ...set-clears-partial-record-selection.bad.al | 16 +++++ ...et-clears-partial-record-selection.good.al | 15 +++++ .../reset-clears-partial-record-selection.md | 28 +++++++++ ...dfields-on-write-and-transferfields.bad.al | 16 +++++ ...fields-on-write-and-transferfields.good.al | 15 +++++ ...tloadfields-on-write-and-transferfields.md | 28 +++++++++ ...dicated-lookup-pages-not-full-lists.bad.al | 60 ++++++++++++++++++ ...icated-lookup-pages-not-full-lists.good.al | 57 +++++++++++++++++ ...e-dedicated-lookup-pages-not-full-lists.md | 28 +++++++++ ...lidate-on-partial-record-forces-jit.bad.al | 16 +++++ ...idate-on-partial-record-forces-jit.good.al | 16 +++++ .../validate-on-partial-record-forces-jit.md | 28 +++++++++ 48 files changed, 1229 insertions(+) create mode 100644 community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.bad.al create mode 100644 community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.good.al create mode 100644 community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md create mode 100644 community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.bad.al create mode 100644 community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.good.al create mode 100644 community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md create mode 100644 community/knowledge/performance/changecompany-in-loop-drops-caches.bad.al create mode 100644 community/knowledge/performance/changecompany-in-loop-drops-caches.good.al create mode 100644 community/knowledge/performance/changecompany-in-loop-drops-caches.md create mode 100644 community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.bad.al create mode 100644 community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.good.al create mode 100644 community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md create mode 100644 community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.bad.al create mode 100644 community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.good.al create mode 100644 community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md create mode 100644 community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.bad.al create mode 100644 community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al create mode 100644 community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md create mode 100644 community/knowledge/performance/isempty-before-findset-is-extra-round-trip.bad.al create mode 100644 community/knowledge/performance/isempty-before-findset-is-extra-round-trip.good.al create mode 100644 community/knowledge/performance/isempty-before-findset-is-extra-round-trip.md create mode 100644 community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.bad.al create mode 100644 community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al create mode 100644 community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md create mode 100644 community/knowledge/performance/page-background-tasks-for-expensive-cues.bad.al create mode 100644 community/knowledge/performance/page-background-tasks-for-expensive-cues.good.al create mode 100644 community/knowledge/performance/page-background-tasks-for-expensive-cues.md create mode 100644 community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.bad.al create mode 100644 community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.good.al create mode 100644 community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md create mode 100644 community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.bad.al create mode 100644 community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.good.al create mode 100644 community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md create mode 100644 community/knowledge/performance/query-results-bypass-primary-key-cache.bad.al create mode 100644 community/knowledge/performance/query-results-bypass-primary-key-cache.good.al create mode 100644 community/knowledge/performance/query-results-bypass-primary-key-cache.md create mode 100644 community/knowledge/performance/reset-clears-partial-record-selection.bad.al create mode 100644 community/knowledge/performance/reset-clears-partial-record-selection.good.al create mode 100644 community/knowledge/performance/reset-clears-partial-record-selection.md create mode 100644 community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al create mode 100644 community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al create mode 100644 community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md create mode 100644 community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al create mode 100644 community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.good.al create mode 100644 community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md create mode 100644 community/knowledge/performance/validate-on-partial-record-forces-jit.bad.al create mode 100644 community/knowledge/performance/validate-on-partial-record-forces-jit.good.al create mode 100644 community/knowledge/performance/validate-on-partial-record-forces-jit.md diff --git a/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.bad.al b/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.bad.al new file mode 100644 index 0000000..8fdd81f --- /dev/null +++ b/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.bad.al @@ -0,0 +1,24 @@ +page 50100 "CurrPage Update OAGR Bad" +{ + PageType = List; + SourceTable = Customer; + ApplicationArea = All; + + layout + { + area(content) + { + repeater(Rows) + { + field("No."; Rec."No.") { } + field(Name; Rec.Name) { } + } + } + } + + trigger OnAfterGetRecord() + begin + // Update from OnAfterGetRecord re-enters the trigger on every row. + CurrPage.Update(false); + end; +} diff --git a/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.good.al b/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.good.al new file mode 100644 index 0000000..9463162 --- /dev/null +++ b/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.good.al @@ -0,0 +1,26 @@ +page 50100 "CurrPage Update OAGR Good" +{ + PageType = List; + SourceTable = Customer; + ApplicationArea = All; + + layout + { + area(content) + { + repeater(Rows) + { + field("No."; Rec."No.") { } + field(Warning; WarningText) { } + } + } + } + + var + WarningText: Text[50]; + + trigger OnAfterGetRecord() + begin + WarningText := CopyStr(Rec.Name, 1, MaxStrLen(WarningText)); + end; +} diff --git a/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md b/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md new file mode 100644 index 0000000..20e6968 --- /dev/null +++ b/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [currpage-update, onaftergetrecord, list-page, scroll, refresh] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not call CurrPage.Update inside OnAfterGetRecord + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`OnAfterGetRecord` on a list already runs once per visible row on scroll and refresh. `CurrPage.Update` asks the page to reload, which fires those triggers again. The result is a refresh loop or a stutter on every row paint. Official developer performance guidance lists `CurrPage.Update()` in `OnAfterGetRecord` next to `Modify` as work that must not live there. Sibling of `do-not-modify-in-onaftergetrecord.md` (writes); this file is the client refresh half. + +## Best Practice + +Put display-only results in page variables assigned in `OnAfterGetRecord` without calling `Update`. If the page must refresh after an action, call `CurrPage.Update(false)` from `OnAction` once, not per row. + +See sample: `avoid-currpage-update-in-onaftergetrecord.good.al`. + +## Anti Pattern + +`trigger OnAfterGetRecord() begin ... CurrPage.Update(); end;` on a list. The signal is `CurrPage.Update` inside `OnAfterGetRecord` or `OnAfterGetCurrRecord` without an explicit user action. + +See sample: `avoid-currpage-update-in-onaftergetrecord.bad.al`. diff --git a/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.bad.al b/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.bad.al new file mode 100644 index 0000000..e17624e --- /dev/null +++ b/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.bad.al @@ -0,0 +1,20 @@ +codeunit 50100 "Batch NoSeries Insert Bad" +{ + procedure InsertDraftOrders(var Customer: Record Customer) + var + SalesHeader: Record "Sales Header"; + SalesSetup: Record "Sales & Receivables Setup"; + NoSeries: Codeunit "No. Series"; + begin + SalesSetup.Get(); + if Customer.FindSet() then + repeat + SalesHeader.Init(); + SalesHeader."Document Type" := SalesHeader."Document Type"::Order; + // Per-row GetNextNo locks the number-series line every insert. + SalesHeader."No." := NoSeries.GetNextNo(SalesSetup."Order Nos.", WorkDate()); + SalesHeader."Sell-to Customer No." := Customer."No."; + SalesHeader.Insert(true); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.good.al b/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.good.al new file mode 100644 index 0000000..236fef0 --- /dev/null +++ b/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.good.al @@ -0,0 +1,20 @@ +codeunit 50100 "Batch NoSeries Insert Good" +{ + procedure InsertDraftOrders(var Customer: Record Customer) + var + SalesHeader: Record "Sales Header"; + SalesSetup: Record "Sales & Receivables Setup"; + NoSeriesBatch: Codeunit "No. Series - Batch"; + begin + SalesSetup.Get(); + if Customer.FindSet() then + repeat + SalesHeader.Init(); + SalesHeader."Document Type" := SalesHeader."Document Type"::Order; + SalesHeader."No." := NoSeriesBatch.GetNextNo(SalesSetup."Order Nos.", WorkDate()); + SalesHeader."Sell-to Customer No." := Customer."No."; + SalesHeader.Insert(true); + until Customer.Next() = 0; + NoSeriesBatch.SaveState(); + end; +} diff --git a/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md b/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md new file mode 100644 index 0000000..616c789 --- /dev/null +++ b/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md @@ -0,0 +1,28 @@ +--- +bc-version: [22..] +domain: performance +keywords: [no-series, getnextno, no-series-batch, savestate, numbersequence, lock] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Batch number-series calls instead of GetNextNo per insert + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`Codeunit "No. Series".GetNextNo` on a **gapless (Normal)** series updates and locks the number-series line on every call. A tight `Insert` loop that asks for a number per row serializes every concurrent writer on that series — the classic SaaS posting bottleneck. Training data still copies the per-row C/AL `NoSeriesManagement` shape. Series configured with **Allow Gaps** instead obtain numbers through `NumberSequence` and do not hold the series-line lock between calls, so they are not affected by this pattern. Codeunit `"No. Series - Batch"` issues gapless numbers in memory and writes the series line once via `SaveState`. + +## Best Practice + +Inside a multi-row insert, call `"No. Series - Batch".GetNextNo` per row and `SaveState` once after the loop when the series must remain gapless. Use `NumberSequence.Next` when holes are allowed. Do not replace a single `OnInsert` `GetNextNo` for one master record; that path is not the hotspot. + +See sample: `batch-number-series-instead-of-getnextno-per-row.good.al`. + +## Anti Pattern + +`NoSeries.GetNextNo(...)` inside `repeat ... Insert ... until Next() = 0` where the series is **gapless** (Allow Gaps = false). Each iteration takes the series-line lock. The signal is `"No. Series"` (not `"No. Series - Batch"`) in a loop that inserts more than one row; do not flag the same pattern when the series has Allow Gaps enabled, as the `NumberSequence` path already avoids the lock. + +See sample: `batch-number-series-instead-of-getnextno-per-row.bad.al`. diff --git a/community/knowledge/performance/changecompany-in-loop-drops-caches.bad.al b/community/knowledge/performance/changecompany-in-loop-drops-caches.bad.al new file mode 100644 index 0000000..12aefe0 --- /dev/null +++ b/community/knowledge/performance/changecompany-in-loop-drops-caches.bad.al @@ -0,0 +1,20 @@ +codeunit 50100 "ChangeCompany Loop Bad" +{ + procedure NamesForCustomers(var Buffer: Record Customer) + var + Customer: Record Customer; + Company: Record Company; + begin + Customer.SetLoadFields(Name); + if Buffer.FindSet() then + repeat + if Company.FindSet() then + repeat + // ChangeCompany per customer per company resets caches every row. + Customer.ChangeCompany(Company.Name); + if Customer.Get(Buffer."No.") then + Message(Customer.Name); + until Company.Next() = 0; + until Buffer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/changecompany-in-loop-drops-caches.good.al b/community/knowledge/performance/changecompany-in-loop-drops-caches.good.al new file mode 100644 index 0000000..ffb4f3b --- /dev/null +++ b/community/knowledge/performance/changecompany-in-loop-drops-caches.good.al @@ -0,0 +1,19 @@ +codeunit 50100 "ChangeCompany Loop Good" +{ + procedure NamesForCustomers(var Buffer: Record Customer) + var + Customer: Record Customer; + Company: Record Company; + begin + Customer.SetLoadFields(Name); + if Company.FindSet() then + repeat + Customer.ChangeCompany(Company.Name); + if Buffer.FindSet() then + repeat + if Customer.Get(Buffer."No.") then + Message(Customer.Name); + until Buffer.Next() = 0; + until Company.Next() = 0; + end; +} diff --git a/community/knowledge/performance/changecompany-in-loop-drops-caches.md b/community/knowledge/performance/changecompany-in-loop-drops-caches.md new file mode 100644 index 0000000..fddef76 --- /dev/null +++ b/community/knowledge/performance/changecompany-in-loop-drops-caches.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [changecompany, loop, cache, multi-company, isolation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not call ChangeCompany inside a per-row loop + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`ChangeCompany` retargets a record variable to another company's data and drops the in-memory caches bound to the previous company. Calling it once per row in a multi-company scan therefore pays a cache reset on every iteration, even when consecutive rows share a company. Agents treat `ChangeCompany` like a filter. It is an isolation switch. + +## Best Practice + +Group work by company. Call `ChangeCompany` once per distinct company, then `FindSet`/`Get` that company's rows. If the record variable is reused afterward, call `ChangeCompany()` without a company name to redirect it back to the current company. + +See sample: `changecompany-in-loop-drops-caches.good.al`. + +## Anti Pattern + +`repeat Rec.ChangeCompany(Buffer.Company); Rec.Get(Buffer."No."); until Buffer.Next() = 0` when `Buffer` is not ordered by company, or even when it is — if `ChangeCompany` still runs every row. The signal is `ChangeCompany` inside `repeat`/`while` keyed by a document line rather than by a company loop. + +See sample: `changecompany-in-loop-drops-caches.bad.al`. diff --git a/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.bad.al b/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.bad.al new file mode 100644 index 0000000..1ef1fe4 --- /dev/null +++ b/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.bad.al @@ -0,0 +1,15 @@ +report 50100 "Cust List ReadOnly Bad" +{ + UsageCategory = ReportsAndAnalysis; + ApplicationArea = All; + // Missing DataAccessIntent = ReadOnly; the scan hits the primary replica. + + dataset + { + dataitem(Customer; Customer) + { + column(No; "No.") { } + column(Name; Name) { } + } + } +} diff --git a/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.good.al b/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.good.al new file mode 100644 index 0000000..07f22d0 --- /dev/null +++ b/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.good.al @@ -0,0 +1,15 @@ +report 50100 "Cust List ReadOnly Good" +{ + UsageCategory = ReportsAndAnalysis; + ApplicationArea = All; + DataAccessIntent = ReadOnly; + + dataset + { + dataitem(Customer; Customer) + { + column(No; "No.") { } + column(Name; Name) { } + } + } +} diff --git a/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md b/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md new file mode 100644 index 0000000..89ee55e --- /dev/null +++ b/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md @@ -0,0 +1,28 @@ +--- +bc-version: ["16.."] +domain: performance +keywords: [dataaccessintent, read-only, read-scale-out, report, api-page, query] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Set DataAccessIntent ReadOnly on analytical objects + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`DataAccessIntent` was introduced at runtime 5.0 (BC 16) and has no effect in earlier versions. Reports, API pages (`PageType = API` with `Editable = false`), and queries that only read can run against a read replica when `DataAccessIntent = ReadOnly`. For queries, replica routing only applies when the query is exposed via OData/API; running a query in AL code is unaffected. Without the property these objects hit the primary replica and compete with posting. Agents omit it because the default is read-write and the object "only reads" in AL. The replica routing is a metadata switch, not something the compiler infers from the absence of `Modify`. + +## Best Practice + +On report objects and `PageType = API` pages with `Editable = false` that never write, set `DataAccessIntent = ReadOnly`. For query objects, set it when the query is consumed via OData or an API endpoint. Keep the default on objects that insert, modify, or call a write codeunit from a processing-only report. + +See sample: `dataaccessintent-readonly-on-analytical-objects.good.al`. + +## Anti Pattern + +A listing report or API query with no `DataAccessIntent` that scans G/L or sales lines. The object is read-only in practice and still loads the primary. + +See sample: `dataaccessintent-readonly-on-analytical-objects.bad.al`. diff --git a/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.bad.al b/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.bad.al new file mode 100644 index 0000000..1a5c4aa --- /dev/null +++ b/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.bad.al @@ -0,0 +1,34 @@ +page 50100 "GuiAllowed OData Guard Bad" +{ + PageType = List; + SourceTable = Customer; + ApplicationArea = All; + + layout + { + area(content) + { + repeater(Rows) + { + field("No."; Rec."No.") { } + field(Name; Rec.Name) + { + StyleExpr = NameStyle; + } + } + } + } + + var + NameStyle: Text; + + trigger OnAfterGetRecord() + begin + // UI-only styling still runs for every OData / Edit-in-Excel row. + Rec.CalcFields("Balance (LCY)"); + if Rec."Balance (LCY)" > 0 then + NameStyle := 'Attention' + else + NameStyle := 'Standard'; + end; +} diff --git a/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.good.al b/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.good.al new file mode 100644 index 0000000..168afbe --- /dev/null +++ b/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.good.al @@ -0,0 +1,35 @@ +page 50100 "GuiAllowed OData Guard Good" +{ + PageType = List; + SourceTable = Customer; + ApplicationArea = All; + + layout + { + area(content) + { + repeater(Rows) + { + field("No."; Rec."No.") { } + field(Name; Rec.Name) + { + StyleExpr = NameStyle; + } + } + } + } + + var + NameStyle: Text; + + trigger OnAfterGetRecord() + begin + if not GuiAllowed then + exit; + Rec.CalcFields("Balance (LCY)"); + if Rec."Balance (LCY)" > 0 then + NameStyle := 'Attention' + else + NameStyle := 'Standard'; + end; +} diff --git a/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md b/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md new file mode 100644 index 0000000..01dc1f8 --- /dev/null +++ b/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [guiallowed, clienttype, odata, edit-in-excel, page-trigger, factbox] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Guard page trigger work with GuiAllowed for OData and Excel + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Pages exposed as OData, including Edit in Excel, still run AL page triggers for every row returned. FactBox updates, defaulting, and extra `CalcFields` in `OnAfterGetRecord` therefore run on the web-service path where no UI exists. `GuiAllowed` is false for those sessions. Agents add page logic as if only the browser client will execute it. + +## Best Practice + +Wrap UI-only work — FactBox refresh, notifications, defaulting that is not part of the web-service contract — in `if GuiAllowed then`. Keep the OData path to field values the API actually returns. + +See sample: `guiallowed-guard-on-pages-used-as-odata.good.al`. + +## Anti Pattern + +Unconditional FactBox or calculation logic in `OnAfterGetRecord` / `OnAfterGetCurrRecord` on a page that is published as a web service or used with Edit in Excel. The signal is trigger work that calls `CurrPage` parts or extra queries without a `GuiAllowed` guard. + +See sample: `guiallowed-guard-on-pages-used-as-odata.bad.al`. diff --git a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.bad.al b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.bad.al new file mode 100644 index 0000000..8297ffe --- /dev/null +++ b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.bad.al @@ -0,0 +1,13 @@ +codeunit 50100 "HttpClient Holds Locks Bad" +{ + procedure SyncCustomerLastName(var Customer: Record Customer) + var + Client: HttpClient; + Response: HttpResponseMessage; + begin + Customer."Search Name" := Customer.Name; + Customer.Modify(false); + // Locks from Modify are held for the entire HTTP wait. + Client.Get(StrSubstNo('https://example.local/sync/%1', Customer."No."), Response); + end; +} diff --git a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al new file mode 100644 index 0000000..903fb2e --- /dev/null +++ b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al @@ -0,0 +1,62 @@ +codeunit 50100 "HttpClient Holds Locks Good" +{ + procedure SyncCustomerLastName(var Customer: Record Customer) + var + CustomerSyncOutbox: Record "Customer Sync Outbox"; + begin + Customer."Search Name" := Customer.Name; + Customer.Modify(false); + + // This work item commits or rolls back with the customer change. + CustomerSyncOutbox."Customer No." := Customer."No."; + CustomerSyncOutbox.Insert(); + end; +} + +table 50100 "Customer Sync Outbox" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) + { + AutoIncrement = true; + } + field(2; "Customer No."; Code[20]) { } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } +} + +codeunit 50101 "Customer Sync Outbox Worker" +{ + // Configure this codeunit as a recurring job queue entry. + TableNo = "Job Queue Entry"; + + trigger OnRun() + var + Customer: Record Customer; + CustomerSyncOutbox: Record "Customer Sync Outbox"; + Client: HttpClient; + Response: HttpResponseMessage; + begin + // Only committed work is visible here; a rolled-back change leaves no outbox row. + if not CustomerSyncOutbox.FindFirst() then + exit; + + Customer.Get(CustomerSyncOutbox."Customer No."); + Client.Get(StrSubstNo('https://example.local/sync/%1', Customer."No."), Response); + if not Response.IsSuccessStatusCode() then + Error('Customer sync failed with HTTP status %1.', Response.HttpStatusCode()); + + // Delete only after HTTP completes, so no write lock is held during the call. + CustomerSyncOutbox.Delete(); + end; +} diff --git a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md new file mode 100644 index 0000000..4c1d500 --- /dev/null +++ b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: performance +keywords: [httpclient, write-transaction, lock, commit, outbound-http, session-block] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not call HttpClient inside an open write transaction + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +The first database write opens an AL write transaction that the runtime holds until the execution completes or `Commit()` runs — see `understand-implicit-transaction-boundary.md`. `HttpClient` blocks the session until the remote call returns. Any locks taken by earlier `Insert`/`Modify`/`Delete` therefore stay held for the HTTP wall-clock time, and interactive users see a spinner. This is not generic "don't block": it is the AL transaction model plus lock lifetime around outbound I/O. + +## Best Practice + +Defer the HTTP call to a separate session. When the external operation must correspond to a committed database change, insert an outbox work item in the same transaction as that change and process committed outbox rows with a recurring job queue entry. The change and work item then commit or roll back together, and the worker performs HTTP before deleting the item so it holds no write lock during the call. Make the external operation idempotent because a failure after a successful HTTP response can cause the work item to be retried. + +A directly created scheduled task is suitable only when its work is independent of the caller's commit. An immediately ready task can run concurrently with the caller, so it must not assume that the caller's writes are already committed. Do **not** use `Commit()` as a general remedy: it irrevocably commits all prior writes in the current transaction, so any subsequent failure cannot roll them back. `Commit()` is appropriate only at top-level entry points where partial persistence is intentional and understood. + +See sample: `httpclient-inside-write-transaction-holds-locks.good.al`. + +## Anti Pattern + +`Modify`/`Insert` followed by `HttpClient` in the same procedure with no `Commit` between them. Detection signal: any `HttpClient` use after a write on the same execution path, especially in posting, page actions, or subscribers. + +See sample: `httpclient-inside-write-transaction-holds-locks.bad.al`. diff --git a/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.bad.al b/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.bad.al new file mode 100644 index 0000000..f11239e --- /dev/null +++ b/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.bad.al @@ -0,0 +1,16 @@ +codeunit 50100 "IsEmpty Before FindSet Bad" +{ + procedure ListUsCustomerNames() + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + // IsEmpty does not replace FindSet; it adds a second round-trip. + if not Customer.IsEmpty() then + if Customer.FindSet() then + repeat + Message(Customer.Name); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.good.al b/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.good.al new file mode 100644 index 0000000..e018fb5 --- /dev/null +++ b/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.good.al @@ -0,0 +1,14 @@ +codeunit 50100 "IsEmpty Before FindSet Good" +{ + procedure ListUsCustomerNames() + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then + repeat + Message(Customer.Name); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.md b/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.md new file mode 100644 index 0000000..a8bf187 --- /dev/null +++ b/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [isempty, findset, extra-round-trip, existence-check, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# IsEmpty immediately before FindSet is an extra round-trip + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`IsEmpty` is the right API when the caller only needs existence — see `microsoft/knowledge/performance/use-isempty-for-existence-check.md`. It is not a cheap guard in front of a loop that will `FindSet` anyway. Both calls hit the database; `FindSet` already returns false when the filter matches nothing. Agents and reviewers often insert `if not Rec.IsEmpty() then` "for performance" and pay a second query for a result the iterator already provides. + +## Best Practice + +When the body iterates, open with `if Rec.FindSet() then repeat ... until Next() = 0`. Do not flag a bare `FindSet` loop as missing an `IsEmpty` precondition. Reserve `IsEmpty` for branches that never materialize the row set. + +See sample: `isempty-before-findset-is-extra-round-trip.good.al`. + +## Anti Pattern + +`if not Rec.IsEmpty() then if Rec.FindSet() then repeat`. Also a false-positive review comment that asks to add that guard. The second read does not avoid the first; it duplicates it. + +See sample: `isempty-before-findset-is-extra-round-trip.bad.al`. diff --git a/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.bad.al b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.bad.al new file mode 100644 index 0000000..7d88fb7 --- /dev/null +++ b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.bad.al @@ -0,0 +1,15 @@ +codeunit 50100 "Login Subscriber IO Bad" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"System Initialization", OnAfterLogin, '', false, false)] + local procedure OnAfterLogin() + var + Client: HttpClient; + Response: HttpResponseMessage; + GLEntry: Record "G/L Entry"; + begin + // Blocks UI, API, and job-queue session creation until HTTP and SQL finish. + Client.Get('https://example.local/warmup', Response); + GLEntry.SetLoadFields("Entry No."); + if GLEntry.FindLast() then; + end; +} diff --git a/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al new file mode 100644 index 0000000..d1b3aa5 --- /dev/null +++ b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al @@ -0,0 +1,30 @@ +codeunit 50100 "Login Subscriber IO Good" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"System Initialization", OnAfterLogin, '', false, false)] + local procedure OnAfterLogin() + var + TaskId: Guid; + StoredId: Text; + begin + // Guard to interactive sessions only; background task sessions also raise OnAfterLogin. + if not (Session.CurrentClientType() in [ClientType::Web, ClientType::Windows, ClientType::Desktop, ClientType::Tablet, ClientType::Phone]) then + exit; + + // Idempotent: TaskExists requires the GUID returned by CreateTask, stored across logins. + if IsolatedStorage.Get('LoginSyncTaskId', DataScope::Company, StoredId) then + if Evaluate(TaskId, StoredId) then + if TaskScheduler.TaskExists(TaskId) then + exit; + + TaskId := TaskScheduler.CreateTask(Codeunit::"Login Subscriber IO Work", 0, true, CompanyName(), CurrentDateTime() + 60000); + IsolatedStorage.Set('LoginSyncTaskId', Format(TaskId), DataScope::Company); + end; +} + +codeunit 50101 "Login Subscriber IO Work" +{ + trigger OnRun() + begin + // Isolated from session creation: outbound I/O is safe here. + end; +} diff --git a/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md new file mode 100644 index 0000000..3cb8459 --- /dev/null +++ b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [oncompanyopen, onafterlogin, session-start, httpclient, subscriber, login] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Session-open subscribers must not do I/O + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`OnCompanyOpen`, `OnCompanyOpenCompleted`, and `System Initialization`.OnAfterLogin run while the session is being created. The platform waits until every subscriber returns before the UI, an API call, or a background session can proceed. `HttpClient` or a heavy `FindSet` here delays **every** session type, not just the user who "opened the company". Agents still put warmup sync, license checks, and HTTP probes on these events because they look like an application startup hook. + +## Best Practice + +Keep company-open subscribers to cheap in-memory work: set a flag, enqueue a job-queue entry, or `TaskScheduler.CreateTask`. Perform HTTP and large SQL after the session is running, in that background work. + +See sample: `oncompanyopen-subscribers-must-not-do-io.good.al`. + +## Anti Pattern + +An `OnAfterLogin` / `OnCompanyOpenCompleted` subscriber that calls `HttpClient` or scans a ledger. Detection signal: `HttpClient`, `FindSet`, or `CalcFields` inside a subscriber bound to those events. + +See sample: `oncompanyopen-subscribers-must-not-do-io.bad.al`. diff --git a/community/knowledge/performance/page-background-tasks-for-expensive-cues.bad.al b/community/knowledge/performance/page-background-tasks-for-expensive-cues.bad.al new file mode 100644 index 0000000..7f4d0e1 --- /dev/null +++ b/community/knowledge/performance/page-background-tasks-for-expensive-cues.bad.al @@ -0,0 +1,31 @@ +page 50100 "Cue Background Task Bad" +{ + PageType = CardPart; + ApplicationArea = All; + + layout + { + area(content) + { + cuegroup(Group) + { + field(OpenOrders; OpenOrderCount) + { + Caption = 'Open Sales Orders'; + } + } + } + } + + var + OpenOrderCount: Integer; + + trigger OnOpenPage() + var + SalesHeader: Record "Sales Header"; + begin + // Blocks Role Center render on an exact count of sales headers. + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + OpenOrderCount := SalesHeader.Count(); + end; +} diff --git a/community/knowledge/performance/page-background-tasks-for-expensive-cues.good.al b/community/knowledge/performance/page-background-tasks-for-expensive-cues.good.al new file mode 100644 index 0000000..2e14a47 --- /dev/null +++ b/community/knowledge/performance/page-background-tasks-for-expensive-cues.good.al @@ -0,0 +1,49 @@ +page 50100 "Cue Background Task Good" +{ + PageType = CardPart; + ApplicationArea = All; + + layout + { + area(content) + { + cuegroup(Group) + { + field(OpenOrders; OpenOrderCount) + { + Caption = 'Open Sales Orders'; + } + } + } + } + + var + OpenOrderCount: Integer; + TaskId: Integer; + + trigger OnAfterGetCurrRecord() + var + Args: Dictionary of [Text, Text]; + begin + CurrPage.EnqueueBackgroundTask(TaskId, Codeunit::"Cue Open Order Count", Args); + end; + + trigger OnPageBackgroundTaskCompleted(CompletedTaskId: Integer; Results: Dictionary of [Text, Text]) + begin + if Results.ContainsKey('Count') then + Evaluate(OpenOrderCount, Results.Get('Count')); + end; +} + +codeunit 50100 "Cue Open Order Count" +{ + trigger OnRun() + var + SalesHeader: Record "Sales Header"; + Results: Dictionary of [Text, Text]; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + Results.Add('Count', Format(SalesHeader.CountApprox())); + Page.SetBackgroundTaskResult(Results); + end; +} diff --git a/community/knowledge/performance/page-background-tasks-for-expensive-cues.md b/community/knowledge/performance/page-background-tasks-for-expensive-cues.md new file mode 100644 index 0000000..48fae1c --- /dev/null +++ b/community/knowledge/performance/page-background-tasks-for-expensive-cues.md @@ -0,0 +1,28 @@ +--- +bc-version: [15..] +domain: performance +keywords: [page-background-task, cue, rolecenter, enqueuebackgroundtask, ui-thread] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Calculate expensive cues on a page background task + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Role-center cues and CardPart totals that run `CalcFields`, scans, or HTTP on the UI thread freeze the shell until they finish. Page background tasks exist to return the page immediately and fill the number later. Enqueue mechanics, cancellation, and the read-only child session are covered in `microsoft/knowledge/ui/page-background-tasks.md`. This file is the performance trigger: a cue whose value is not needed to *open* the page must not run on the render path. + +## Best Practice + +Bind the cue to a page variable, enqueue a read-only calculation from `OnAfterGetCurrRecord` (not `OnAfterGetRecord` on a list), and apply the result in `OnPageBackgroundTaskCompleted`. Show a placeholder until then. + +See sample: `page-background-tasks-for-expensive-cues.good.al`. + +## Anti Pattern + +`CalcFields` or a ledger `Count` in `OnOpenPage` / `OnAfterGetCurrRecord` of a CueGroup CardPart with no background task. The Role Center waits on SQL the user may never look at. + +See sample: `page-background-tasks-for-expensive-cues.bad.al`. diff --git a/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.bad.al b/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.bad.al new file mode 100644 index 0000000..f08e8ad --- /dev/null +++ b/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.bad.al @@ -0,0 +1,20 @@ +codeunit 50100 "Pass Var Enumerator Bad" +{ + procedure ListUsCustomerCities() + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then + repeat + // By-value copy: JIT on City does not update the enumerator. + Message(Customer.Name + ' ' + CityOf(Customer)); + until Customer.Next() = 0; + end; + + local procedure CityOf(Customer: Record Customer): Text + begin + exit(Customer.City); + end; +} diff --git a/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.good.al b/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.good.al new file mode 100644 index 0000000..9cff6e6 --- /dev/null +++ b/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.good.al @@ -0,0 +1,21 @@ +codeunit 50100 "Pass Var Enumerator Good" +{ + procedure ListUsCustomerCities() + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then + repeat + EnsureCityLoaded(Customer); + Message(Customer.Name + ' ' + Customer.City); + until Customer.Next() = 0; + end; + + local procedure EnsureCityLoaded(var Customer: Record Customer) + begin + if not Customer.AreFieldsLoaded(Customer.City) then + Customer.LoadFields(Customer.City); + end; +} diff --git a/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md b/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md new file mode 100644 index 0000000..ce4c4bc --- /dev/null +++ b/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [setloadfields, jit-load, enumerator, var-parameter, pass-by-value, next] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pass the iterated record var so a JIT load updates the enumerator + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +A `FindSet`/`Next` loop builds an enumerator from the fields selected for load. Accessing an unloaded field triggers a JIT load. When the record is passed **by value**, the copy does not share that enumerator: the JIT loads the copy and leaves the enumerator unchanged, so **every later `Next()` JIT-loads again**. Passing `var` lets the first JIT update the enumerator. `AddLoadFields` on the original record before a by-value call is the other fix. This is independent of whether `SetLoadFields` was ordered before filters. + +## Best Practice + +Helpers that read extra fields on an in-flight iterator must take the record as `var`, or the caller must `AddLoadFields` those fields before the loop. Prefer declaring the extra fields up front so no JIT is needed. + +See sample: `pass-var-record-to-preserve-partial-load-enumerator.good.al`. + +## Anti Pattern + +A `SetLoadFields` loop that passes the iterator by value into a helper which then reads a field that was not loaded. The first row pays one JIT; every subsequent row pays it again because the enumerator never learned the extra field. + +See sample: `pass-var-record-to-preserve-partial-load-enumerator.bad.al`. diff --git a/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.bad.al b/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.bad.al new file mode 100644 index 0000000..0eb462c --- /dev/null +++ b/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.bad.al @@ -0,0 +1,9 @@ +tableextension 50100 "G/L Entry Extra Ext" extends "G/L Entry" +{ + fields + { + // Stored companion columns are joined on every G/L Entry read. + field(50100; "External Reference"; Text[50]) { } + field(50101; "Integration Payload"; Blob) { } + } +} diff --git a/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.good.al b/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.good.al new file mode 100644 index 0000000..8699db0 --- /dev/null +++ b/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.good.al @@ -0,0 +1,19 @@ +table 50100 "G/L Entry Extra" +{ + Caption = 'G/L Entry Extra'; + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) + { + TableRelation = "G/L Entry"."Entry No."; + } + field(2; "External Reference"; Text[50]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} diff --git a/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md b/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md new file mode 100644 index 0000000..dd03115 --- /dev/null +++ b/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md @@ -0,0 +1,29 @@ +--- +bc-version: ["23.."] +domain: performance +keywords: [tableextension, companion-table, gl-entry, related-table, flowfield, hot-table] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefer a related table over stored fields on hot ledgers + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Since v23, all extensions on the same base table share at most one companion-table join, and the platform automatically excludes that join on List, ListPart, and OData pages when partial records are in effect and no extension field is loaded. However, the join is still paid on every posting path and any AL code that accesses an extension field — or that runs without partial-record semantics. On hot tables — G/L Entry, Item Ledger Entry, Cust. Ledger Entry — even a single access per posted row adds up at volume. A related table keyed by the ledger `Entry No.`, optionally surfaced with a FlowField or FactBox, leaves the base read path entirely untouched. Agents extend G/L Entry because it is "where the posting already is". + +## Best Practice + +Put optional, sparse, or integration attributes in a related table with the ledger entry number as primary key. Show them from a FactBox or a FlowField. +Use a tableextension stored field only when the value must appear as a native list column and is read on almost every access. + +See sample: `prefer-related-table-over-extension-on-hot-ledgers.good.al`. + +## Anti Pattern + +`tableextension` on `"G/L Entry"` (or another posting table) that adds several stored `Text`/`Blob` fields used only by one integration. The companion join is paid on every posting and on any AL code path that loads extension fields, even when those columns are not needed for the current operation. + +See sample: `prefer-related-table-over-extension-on-hot-ledgers.bad.al`. diff --git a/community/knowledge/performance/query-results-bypass-primary-key-cache.bad.al b/community/knowledge/performance/query-results-bypass-primary-key-cache.bad.al new file mode 100644 index 0000000..9de4b03 --- /dev/null +++ b/community/knowledge/performance/query-results-bypass-primary-key-cache.bad.al @@ -0,0 +1,28 @@ +query 50100 "Query Bypass PK Cache Bad Q" +{ + QueryType = Normal; + + elements + { + dataitem(Customer; Customer) + { + filter(NoFilter; "No.") { } + column(Name; Name) { } + } + } +} + +codeunit 50100 "Query Bypass PK Cache Bad" +{ + procedure CustomerName(CustomerNo: Code[20]): Text + var + CustomerByNo: Query "Query Bypass PK Cache Bad Q"; + begin + // Query Open/Read never hits the server PK cache. + CustomerByNo.SetRange(NoFilter, CustomerNo); + CustomerByNo.Open(); + if CustomerByNo.Read() then + exit(CustomerByNo.Name); + CustomerByNo.Close(); + end; +} diff --git a/community/knowledge/performance/query-results-bypass-primary-key-cache.good.al b/community/knowledge/performance/query-results-bypass-primary-key-cache.good.al new file mode 100644 index 0000000..f128f58 --- /dev/null +++ b/community/knowledge/performance/query-results-bypass-primary-key-cache.good.al @@ -0,0 +1,12 @@ +codeunit 50100 "Query Bypass PK Cache Good" +{ + procedure CustomerName(CustomerNo: Code[20]): Text + var + Customer: Record Customer; + begin + // Repeated Get of the same No. is served from the transaction PK cache. + Customer.SetLoadFields(Name); + if Customer.Get(CustomerNo) then + exit(Customer.Name); + end; +} diff --git a/community/knowledge/performance/query-results-bypass-primary-key-cache.md b/community/knowledge/performance/query-results-bypass-primary-key-cache.md new file mode 100644 index 0000000..1f3205f --- /dev/null +++ b/community/knowledge/performance/query-results-bypass-primary-key-cache.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [query, primary-key-cache, get, false-positive, n-plus-one, record-cache] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Query results bypass the primary-key cache + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +The Business Central server caches primary-key `Get` calls within a transaction. Query objects do not use that cache: every `Open`/`Read` goes to SQL. `avoid-get-inside-loop-on-large-table.md` is right when an unbounded inner `Get`/`FindFirst` joins two large sets. It is wrong as a blanket rewrite of repeated `Get` on the same keys. Replacing a cached `Get` with a Query that re-executes per call can be slower. This file exists so reviewers stop treating every `Get` inside a loop as a Query candidate. + +## Best Practice + +Keep `Record.Get` for repeated lookups of the same primary keys in one transaction. Use a Query when the work is a true join or aggregation that the record API would express as nested scans. Do not flag a guarded `Get` on a repeating key as an N+1 solely because a Query could express the same columns. + +See sample: `query-results-bypass-primary-key-cache.good.al`. + +## Anti Pattern + +Rewriting a helper that `Get`s Customer by `No.` on every sales line into a Query opened inside that helper. Distinct line customers still need a lookup; repeating customers were already served from the PK cache. The Query pays SQL every time. + +See sample: `query-results-bypass-primary-key-cache.bad.al`. diff --git a/community/knowledge/performance/reset-clears-partial-record-selection.bad.al b/community/knowledge/performance/reset-clears-partial-record-selection.bad.al new file mode 100644 index 0000000..3b1f999 --- /dev/null +++ b/community/knowledge/performance/reset-clears-partial-record-selection.bad.al @@ -0,0 +1,16 @@ +codeunit 50100 "Reset Clears LoadFields Bad" +{ + procedure ListUsCustomerNames() + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Name); + // Reset restores a full-row load; the SetLoadFields above is discarded. + Customer.Reset(); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then + repeat + Message(Customer.Name); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/reset-clears-partial-record-selection.good.al b/community/knowledge/performance/reset-clears-partial-record-selection.good.al new file mode 100644 index 0000000..bb6ee0d --- /dev/null +++ b/community/knowledge/performance/reset-clears-partial-record-selection.good.al @@ -0,0 +1,15 @@ +codeunit 50100 "Reset Clears LoadFields Good" +{ + procedure ListUsCustomerNames() + var + Customer: Record Customer; + begin + Customer.Reset(); + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then + repeat + Message(Customer.Name); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/reset-clears-partial-record-selection.md b/community/knowledge/performance/reset-clears-partial-record-selection.md new file mode 100644 index 0000000..c99f8d2 --- /dev/null +++ b/community/knowledge/performance/reset-clears-partial-record-selection.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [reset, setloadfields, partial-record, load-selection, findset] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Reset and empty SetLoadFields restore a full-row load + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`SetLoadFields(...)` sticks to the record variable until something clears it. `Reset()` "changes fields select for loading back to all", and `SetLoadFields()` with no arguments does the same. A later `FindSet` or `Get` then materializes every normal field. Agents often place `SetLoadFields` first, then `Reset` to apply new filters, and assume the partial selection survives. It does not. + +## Best Practice + +Call `Reset` (or empty `SetLoadFields()`) first when the variable must be reused, then call `SetLoadFields` with the fields the next read actually uses, then apply filters and read. After `Reset`, a new `SetLoadFields` is required; the previous list is gone. + +See sample: `reset-clears-partial-record-selection.good.al`. + +## Anti Pattern + +`SetLoadFields(...)` followed by `Reset()` (or by parameterless `SetLoadFields()`) and then `FindSet` without restoring the load list. The filters look correct; the SQL still selects every column. + +See sample: `reset-clears-partial-record-selection.bad.al`. diff --git a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al new file mode 100644 index 0000000..aa5a1ca --- /dev/null +++ b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al @@ -0,0 +1,16 @@ +codeunit 50100 "Skip LoadFields Write Bad" +{ + procedure CopyActiveCustomers(var TempCustomer: Record Customer temporary) + var + Customer: Record Customer; + begin + // TransferFields requires all fields; partial load forces JIT per row. + Customer.SetLoadFields("No.", Name); + Customer.SetRange(Blocked, Customer.Blocked::" "); + if Customer.FindSet() then + repeat + TempCustomer.TransferFields(Customer); + TempCustomer.Insert(); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al new file mode 100644 index 0000000..8c6e7f8 --- /dev/null +++ b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al @@ -0,0 +1,15 @@ +codeunit 50100 "Skip LoadFields Write Good" +{ + procedure CopyActiveCustomers(var TempCustomer: Record Customer temporary) + var + Customer: Record Customer; + begin + // TransferFields needs all fields; omit SetLoadFields so the initial read loads the full row. + Customer.SetRange(Blocked, Customer.Blocked::" "); + if Customer.FindSet() then + repeat + TempCustomer.TransferFields(Customer); + TempCustomer.Insert(); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md new file mode 100644 index 0000000..8077f28 --- /dev/null +++ b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [setloadfields, partial-record, jit-load, modify, insert, transferfields, write-path] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Skip SetLoadFields on write and copy paths + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`SetLoadFields` is a read optimization. The platform's [partial-record usage guidelines](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-partial-records#usage-guidelines) list the operations that require every field to already be present: `Insert`, `Delete`, `Rename`, `TransferFields`, and copying a record into a temporary table. When those operations run on a partial record, the platform issues a just-in-time load of the missing fields. That extra round-trip costs more than loading the full row on the original `FindSet` or `Get`. Note: `Modify` itself is **not** in this list — a `Modify(false)` that only touches loaded fields is safe with a partial record. + +## Best Practice + +Omit `SetLoadFields` on loops whose body performs a documented full-load operation (`Insert`, `Delete`, `Rename`, `TransferFields`, or assignment into a temporary record) on the same record variable, so the initial read already materializes every field those operations need. + +See sample: `skip-setloadfields-on-write-and-transferfields.good.al`. + +## Anti Pattern + +Calling `SetLoadFields` immediately before a `FindSet` whose body performs `Delete`, `Rename`, `TransferFields`, or copies the record into a temporary table. The review signal is a partial-record setup on a record variable that feeds one of these documented full-load operations in the same iteration. + +See sample: `skip-setloadfields-on-write-and-transferfields.bad.al`. diff --git a/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al b/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al new file mode 100644 index 0000000..4cb842d --- /dev/null +++ b/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al @@ -0,0 +1,60 @@ +table 50100 "Campaign Member" +{ + Caption = 'Campaign Member'; + // Full list as lookup runs FactBoxes and extra columns on every dropdown. + LookupPageId = Page::"Campaign Member List"; + DrillDownPageId = Page::"Campaign Member List"; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { } + field(2; Name; Text[100]) { } + field(3; "Balance (LCY)"; Decimal) { } + } + + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +page 50100 "Campaign Member List" +{ + PageType = List; + SourceTable = "Campaign Member"; + + layout + { + area(content) + { + repeater(Rows) + { + field("No."; Rec."No.") { } + field(Name; Rec.Name) { } + field("Balance (LCY)"; Rec."Balance (LCY)") { } + } + } + area(factboxes) + { + // Full list carries this FactBox on every dropdown open — expensive. + part(Details; "Campaign Member Details FB") { } + } + } +} + +page 50101 "Campaign Member Details FB" +{ + PageType = CardPart; + SourceTable = "Campaign Member"; + + layout + { + area(content) + { + field("No."; Rec."No.") { } + field(Name; Rec.Name) { } + field("Balance (LCY)"; Rec."Balance (LCY)") { } + } + } +} diff --git a/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.good.al b/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.good.al new file mode 100644 index 0000000..db69089 --- /dev/null +++ b/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.good.al @@ -0,0 +1,57 @@ +table 50100 "Campaign Member" +{ + Caption = 'Campaign Member'; + LookupPageId = Page::"Campaign Member Lookup"; + DrillDownPageId = Page::"Campaign Member List"; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { } + field(2; Name; Text[100]) { } + field(3; "Balance (LCY)"; Decimal) { } + } + + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +page 50100 "Campaign Member Lookup" +{ + PageType = List; + SourceTable = "Campaign Member"; + Caption = 'Campaign Members'; + + layout + { + area(content) + { + repeater(Rows) + { + field("No."; Rec."No.") { } + field(Name; Rec.Name) { } + } + } + } +} + +page 50101 "Campaign Member List" +{ + PageType = List; + SourceTable = "Campaign Member"; + + layout + { + area(content) + { + repeater(Rows) + { + field("No."; Rec."No.") { } + field(Name; Rec.Name) { } + field("Balance (LCY)"; Rec."Balance (LCY)") { } + } + } + } +} diff --git a/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md b/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md new file mode 100644 index 0000000..191568e --- /dev/null +++ b/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [lookuppageid, lookup-page, list-page, factbox, table-relation, dropdown] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Point lookups at a dedicated lookup page, not the full list + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +A `TableRelation` lookup opens the table's `LookupPageId`. If that is the full list page, the lookup runs that page's triggers, FactBoxes, and calculated fields even though the dropdown never shows them. The base application added dedicated Customer, Vendor, and Item lookup pages for this reason. Agents set `LookupPageId` to the main list because it already exists. + +## Best Practice + +Give master tables a slim lookup page (`PageType = List`, few columns, no FactBoxes, no heavy `OnAfterGetRecord`) and assign it to `LookupPageId`. Keep the full list for `DrillDownPageId` and the role-explorer entry. + +See sample: `use-dedicated-lookup-pages-not-full-lists.good.al`. + +## Anti Pattern + +`LookupPageId = Page::"... List"` on a table that already has (or should have) a lookup page. Opening a field lookup then pays list-page cost. The signal is `LookupPageId` pointing at a page that declares FactBoxes or a wide repeater. + +See sample: `use-dedicated-lookup-pages-not-full-lists.bad.al`. diff --git a/community/knowledge/performance/validate-on-partial-record-forces-jit.bad.al b/community/knowledge/performance/validate-on-partial-record-forces-jit.bad.al new file mode 100644 index 0000000..248d1a1 --- /dev/null +++ b/community/knowledge/performance/validate-on-partial-record-forces-jit.bad.al @@ -0,0 +1,16 @@ +codeunit 50100 "Validate Partial Rec Bad" +{ + procedure UppercaseUsCustomerNames() + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet(true) then + repeat + // Validate touches other fields and TableRelation reads; JIT undoes the partial load. + Customer.Validate(Name, UpperCase(Customer.Name)); + Customer.Modify(false); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/validate-on-partial-record-forces-jit.good.al b/community/knowledge/performance/validate-on-partial-record-forces-jit.good.al new file mode 100644 index 0000000..7e85c18 --- /dev/null +++ b/community/knowledge/performance/validate-on-partial-record-forces-jit.good.al @@ -0,0 +1,16 @@ +codeunit 50100 "Validate Partial Rec Good" +{ + procedure UppercaseUsCustomerNames() + var + Customer: Record Customer; + begin + // Include every field that Name.OnValidate reads so the runtime never JIT-loads. + Customer.SetLoadFields(Name, "Search Name"); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet(true) then + repeat + Customer.Validate(Name, UpperCase(Customer.Name)); + Customer.Modify(false); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/validate-on-partial-record-forces-jit.md b/community/knowledge/performance/validate-on-partial-record-forces-jit.md new file mode 100644 index 0000000..00b9657 --- /dev/null +++ b/community/knowledge/performance/validate-on-partial-record-forces-jit.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [validate, setloadfields, jit-load, table-relation, onvalidate, partial-record] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Validate on a partial record forces JIT loads + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`Validate` runs the field's `OnValidate` trigger and TableRelation lookups. Those code paths routinely touch other fields on the same record. On a partial row those extra fields are not loaded, so the platform JIT-loads them — often the rest of the row — plus any related-table reads the trigger performs. Distinct from `skip-setloadfields-on-write-and-transferfields.md`: the write may be `Modify(false)`; `Validate` is what blows the partial load. Agents that combine `SetLoadFields` with `Validate` in a loop produce slower code than an unoptimized assignment. + +## Best Practice + +In a partial-record loop, assign fields directly when trigger side effects are not required. If `Validate` is required, do not use `SetLoadFields` on that iterator, or `AddLoadFields` every field the validate path can touch before the read. + +See sample: `validate-on-partial-record-forces-jit.good.al`. + +## Anti Pattern + +`SetLoadFields` on a handful of columns, then `Validate` inside the loop. The load list looks optimal; runtime JIT and TableRelation I/O dominate. The signal is `Validate(` on a record that still has a `SetLoadFields` in the same procedure. + +See sample: `validate-on-partial-record-forces-jit.bad.al`. From 85ceaaa1a49057a5eeef5866d795326d30c57a6a Mon Sep 17 00:00:00 2001 From: Wenjie Fan <31087545+gggdttt@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:07:05 +0200 Subject: [PATCH 49/86] knowledge(style): event subscribers bind by parameter name, so a shorter list is not a mismatch (#138) * knowledge(style): allow event subscribers to omit trailing publisher parameters The article told reviewers to copy the publisher signature exactly and reproduce every parameter verbatim. That contradicts events/add-new-event-parameters-at-the-end, which already states that existing subscribers bind to the leading parameters, and it produced a false positive on BCApps PR 10277 where a subscriber legitimately declared only the leading two of the publisher's three parameters. Clarify that the name-match rule applies to every parameter the subscriber declares, state that AL binds on a leading prefix so trailing parameters may be omitted, and keep the real defect - a subscriber list that is not a prefix of the publisher's - as the anti pattern. Add the false-positive keyword for retrieval. * fix: subscribers bind by parameter name, not by leading prefix The first revision claimed AL binds a subscriber to a leading prefix of the publisher parameter list and that a parameter may not be skipped in the middle. That is wrong. Verified against shipping BCApps code: Test Runner - Mgt publishes OnBeforeTestMethodRun(var CurrentTestMethodLine; CodeunitID; CodeunitName; FunctionName; FunctionTestPermissions; var Skip), and ALTestRunnerResetEnvironment binds to it declaring (CodeunitID; CodeunitName; FunctionName; FunctionTestPermissions; var CurrentTestMethodLine) - omitting Skip and moving the first parameter to last. Binding is by name, so any subset in any order is valid. Detection now targets a parameter whose name or type matches nothing on the publisher. --------- Co-authored-by: wenjiefan --- .../event-subscriber-param-names-match-publisher.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md b/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md index aaf3729..e408ebb 100644 --- a/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md +++ b/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: style -keywords: [event-subscriber, parameter-name, publisher, signature, eventsubscriber] +keywords: [event-subscriber, parameter-name, publisher, signature, eventsubscriber, false-positive] technologies: [al] countries: [w1] application-area: [all] @@ -11,12 +11,16 @@ application-area: [all] ## 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. +In AL, an `[EventSubscriber]` procedure is bound to its publisher by event name and parameter list. For every parameter the subscriber declares, the name is not a style choice — it must match the name 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. + +A subscriber may, however, declare fewer parameters than the publisher. AL binds each subscriber parameter to the publisher parameter of the same name, so the subscriber can omit any parameters its handler does not use, from any position, and can even declare the ones it keeps in a different order than the publisher. This compiles and binds correctly, so a shorter or differently ordered subscriber signature is not a signature mismatch. In shipping BCApps code, `Test Runner - Mgt::OnBeforeTestMethodRun` publishes `CurrentTestMethodLine, CodeunitID, CodeunitName, FunctionName, FunctionTestPermissions, Skip`, and subscribers such as `ALTestRunnerResetEnvironment` bind to it while omitting `Skip` and declaring `CurrentTestMethodLine` last. ## 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. +Copy each parameter's name and type from the publisher verbatim for every parameter the subscriber keeps, and omit the ones the handler does not use. 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. Do not flag a subscriber for declaring fewer parameters than the publisher, for omitting one from the middle of the list, or for declaring them in a different order, as long as every parameter it does declare matches a publisher parameter by name and type: that is valid AL, not a mismatch. ## 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. +Renaming a publisher parameter to look prettier in the subscriber. The build breaks immediately, because the name is what the runtime binds on. 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. + +Detection: a subscriber parameter whose name or type does not correspond to any parameter on the publisher — not a subscriber that merely declares fewer parameters, drops one from the middle, or lists them in a different order. From 00c9307483c2ba394863b624f429bdb7feb19280 Mon Sep 17 00:00:00 2001 From: Wenjie Fan <31087545+gggdttt@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:24:54 +0200 Subject: [PATCH 50/86] knowledge(breaking-changes): adding a parameter to an event publisher is not a signature break (#139) * knowledge(breaking-changes): exclude appended event parameters from the signature-change rule The article's detection guidance flags 'a parameter added' on any shipped procedure. Applied to an event publisher that is bound to rather than called, this produced a false positive on BCApps PR 10278, where a trailing var parameter was appended to the existing IntegrationEvent OnAfterOpenForRecRef and the developer twice replied that adding a parameter to an existing integration event is not a breaking change. It also contradicted events/add-new-event-parameters-at-the-end, which already states that existing subscribers still bind to the leading parameters. Scope the rule to called procedures, carve out appended event parameters as additive, and keep every other event signature edit - removal, reorder, retype, var flip - in scope. Point the IsHandled case at events/do-not-add-ishandled-to-an-existing-event, which owns that semantic concern. * fix: event parameter additions are additive at any position, not only when appended The first revision justified the carve-out with leading-prefix binding and limited it to parameters appended at the end. Verified against shipping BCApps code that AL binds subscriber parameters by name, not position, so an added parameter is additive wherever it is placed. Also corrects the cross-reference to events/adding-a-parameter-to-an-event-is-not-a-breaking-change, which already states this rule, and drops reordering from the list of edits that break binding. * Route event signature edits to the analyzer-backed events article Address review feedback: name AS0025, AS0063 and AS0077 for the var and rename cases instead of claiming them in the breaking-changes article, and point at events/treat-local-and-internal-events-as-subscriber-contracts which already owns them. --------- Co-authored-by: wenjiefan --- .../do-not-change-published-procedure-signatures.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/microsoft/knowledge/breaking-changes/do-not-change-published-procedure-signatures.md b/microsoft/knowledge/breaking-changes/do-not-change-published-procedure-signatures.md index a557d41..75fa6c1 100644 --- a/microsoft/knowledge/breaking-changes/do-not-change-published-procedure-signatures.md +++ b/microsoft/knowledge/breaking-changes/do-not-change-published-procedure-signatures.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: breaking-changes -keywords: [signature, public-procedure, parameter, return-value, overload, contract] +keywords: [signature, public-procedure, parameter, return-value, overload, contract, integration-event] technologies: [al] countries: [w1] application-area: [all] @@ -13,6 +13,8 @@ application-area: [all] A procedure that is reachable from outside its object — any procedure not marked `local` (and, for on-prem-scoped code, anything a dependent app can still bind to) — is a contract. Once another extension compiles against it, changing its shape breaks that extension at build time. Signature changes include adding, removing, or reordering parameters, changing a parameter or return type, and toggling a parameter between by-value and `var` (by-reference). The platform treats the procedure's identity as its full signature, so even a "compatible-looking" tweak is a new method to dependents. There is exactly one safe edit: naming a previously unnamed return value, which adds no caller obligation. LLMs routinely "improve" a public procedure in place by adding a parameter, not realizing every consumer must be recompiled. +This rule governs procedures that dependents *call*. An event publisher — a procedure carrying `[IntegrationEvent]` or `[BusinessEvent]`, conventionally declared `local` — is bound to, not called, and AL binds each subscriber parameter by name rather than by position. Adding a parameter to a shipped event therefore leaves every existing subscriber binding successfully, at any position in the list, so it is additive rather than breaking and must not be flagged under this rule. See `events/adding-a-parameter-to-an-event-is-not-a-breaking-change` for the full treatment, and `events/add-new-event-parameters-at-the-end` for when publisher access does make the addition breaking. Every other edit to a published event signature — removing or retyping a parameter, renaming one, or flipping one to or from `var` — still breaks subscribers, and `events/treat-local-and-internal-events-as-subscriber-contracts` owns that case together with the analyzer rules that enforce it: AS0025 for parameter names and types, AS0063 for removing `var`, and AS0077 for adding it. Adding `var IsHandled: Boolean` is a separate concern: it binds fine but changes the event's contract, and is covered by `events/do-not-add-ishandled-to-an-existing-event`. + ## Best Practice Treat a published signature as frozen. When new behavior needs more inputs, add a new procedure or overload alongside the original — for example a `CalculateDiscountWithRate(Amount; Rate)` next to the unchanged `CalculateDiscount(Amount)` — and let the old one delegate to the new one. Existing callers keep compiling; new callers opt into the richer entry point. Naming an unnamed return value is the one in-place change that is always safe. @@ -21,6 +23,6 @@ See sample: `do-not-change-published-procedure-signatures.good.al`. ## Anti Pattern -Editing the existing public procedure's parameter list — here, adding a `Rate` parameter to `CalculateDiscount` — so every dependent extension that called the old form fails to compile. Detection: a parameter added, removed, reordered, retyped, or flipped to/from `var`, or a changed return type, on any non-`local` procedure that already shipped. Add a new overload instead. +Editing the existing public procedure's parameter list — here, adding a `Rate` parameter to `CalculateDiscount` — so every dependent extension that called the old form fails to compile. Detection: a parameter added, removed, reordered, retyped, or flipped to/from `var`, or a changed return type, on any non-`local` procedure that already shipped. Add a new overload instead. Exclude event publishers whose only change is an added parameter: subscribers bind by parameter name, not position, so that edit is additive and reporting it here is a false positive. See sample: `do-not-change-published-procedure-signatures.bad.al`. From 5016962b40cb7d9a7e4bad30a42e8e4d18f4b5b0 Mon Sep 17 00:00:00 2001 From: wenjiefan Date: Wed, 2 Sep 2026 11:08:16 +0200 Subject: [PATCH 51/86] Align the IsHandled article slug, keywords and good sample with its narrowed scope The article was rewritten to say a reset is required only when the value can carry over, and its H1 was updated to match, but three artefacts still carried the old "always initialize to false" premise: - The slug still read `initialize-ishandled-to-false-before-publishing`, which contradicts the body. The slug is not cosmetic: Build-KnowledgeIndex.ps1 ranks candidates on keywords, frontmatter dimensions, domain, path and title, so a stale path pushes selection back toward the behaviour this change narrows. Renamed to `reset-ishandled-only-when-the-value-can-carry-over`, following the existing precedent for conditional slugs such as `unreleased-symbol-change-is-not-a-breaking-change`. - Keywords still listed `initialization` and `deterministic` and omitted `false-positive`, the tag this repository uses for suppression articles. Replaced with `carry-over` and `loop-iteration` and added `false-positive`. - The good sample demonstrated only the "prefer separate fresh locals" clause and contained no reset at all, so the article's headline case had no positive example. It was also asymmetric with the bad sample, which gained a loop procedure showing a local that carries `true` into the next iteration. Added the matching loop procedure to the good sample: a local declared outside the loop is reset at the top of each iteration. That case cannot be solved by introducing another local, because AL has no block scope, so it is the only shape that demonstrates the reset the article still requires. It also gives the engine the correct `suggested-code` shape for the loop finding; without it the one-click fix adapted from the good sample would propose splitting the variable rather than adding one line. Also renamed the sample codeunits from "IsHandled Init ..." to "IsHandled Carry Over ...", and updated the two references to the old slug: the events leaf skill cue and the events pin in evaluation/review-fixtures.json. validate_frontmatter.py reports 0 errors; Test-ReviewFixtures.ps1 passes with 32 cases across 16 leaf domains and resolves the events fixture to the renamed article. --- evaluation/review-fixtures.json | 2 +- ...only-when-the-value-can-carry-over.bad.al} | 2 +- ...nly-when-the-value-can-carry-over.good.al} | 22 ++++++++++++++++++- ...led-only-when-the-value-can-carry-over.md} | 6 ++--- microsoft/skills/review/al-events-review.md | 2 +- 5 files changed, 27 insertions(+), 7 deletions(-) rename microsoft/knowledge/events/{initialize-ishandled-to-false-before-publishing.bad.al => reset-ishandled-only-when-the-value-can-carry-over.bad.al} (97%) rename microsoft/knowledge/events/{initialize-ishandled-to-false-before-publishing.good.al => reset-ishandled-only-when-the-value-can-carry-over.good.al} (58%) rename microsoft/knowledge/events/{initialize-ishandled-to-false-before-publishing.md => reset-ishandled-only-when-the-value-can-carry-over.md} (89%) diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 2f85d5c..68a646d 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -11,7 +11,7 @@ "article": "do-not-expose-sensitive-data-through-public-api" }, "events": { - "article": "initialize-ishandled-to-false-before-publishing" + "article": "reset-ishandled-only-when-the-value-can-carry-over" }, "interfaces": { "article": "set-defaultimplementation-on-enum" diff --git a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.bad.al b/microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.bad.al similarity index 97% rename from microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.bad.al rename to microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.bad.al index 7192bc1..a7d7709 100644 --- a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.bad.al +++ b/microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.bad.al @@ -1,5 +1,5 @@ // Demonstration-only AL. Not compiled by CI; illustrates the article. -codeunit 50241 "IsHandled Init Bad Sample" +codeunit 50241 "IsHandled Carry Over Bad Sample" { procedure ApplyDiscounts(var SalesHeader: Record "Sales Header") var diff --git a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.good.al b/microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.good.al similarity index 58% rename from microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.good.al rename to microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.good.al index f3e57a3..84d547e 100644 --- a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.good.al +++ b/microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.good.al @@ -1,5 +1,5 @@ // Demonstration-only AL. Not compiled by CI; illustrates the article. -codeunit 50240 "IsHandled Init Good Sample" +codeunit 50240 "IsHandled Carry Over Good Sample" { procedure ApplyDiscounts(var SalesHeader: Record "Sales Header") var @@ -18,6 +18,21 @@ codeunit 50240 "IsHandled Init Good Sample" DiscountPct += 2; end; + procedure ApplyLineDiscounts(var SalesLine: Record "Sales Line") + var + LineIsHandled: Boolean; + begin + if SalesLine.FindSet() then + repeat + // The local initializes once, so reset it per iteration; a + // subscriber that handles one line must not skip the rest. + LineIsHandled := false; + OnBeforeApplyLineDiscount(SalesLine, LineIsHandled); + if not LineIsHandled then + SalesLine.Validate("Line Discount %", 5); + until SalesLine.Next() = 0; + end; + [IntegrationEvent(false, false)] local procedure OnBeforeApplyHeaderDiscount(var SalesHeader: Record "Sales Header"; var DiscountPct: Decimal; var IsHandled: Boolean) begin @@ -27,4 +42,9 @@ codeunit 50240 "IsHandled Init Good Sample" local procedure OnBeforeApplyPaymentDiscount(var SalesHeader: Record "Sales Header"; var DiscountPct: Decimal; var IsHandled: Boolean) begin end; + + [IntegrationEvent(false, false)] + local procedure OnBeforeApplyLineDiscount(var SalesLine: Record "Sales Line"; var IsHandled: Boolean) + begin + end; } diff --git a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.md b/microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.md similarity index 89% rename from microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.md rename to microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.md index 12eb39c..fba378b 100644 --- a/microsoft/knowledge/events/initialize-ishandled-to-false-before-publishing.md +++ b/microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: events -keywords: [ishandled, initialization, deterministic, onbefore, reset, integration-event, control-flow] +keywords: [ishandled, carry-over, loop-iteration, onbefore, reset, integration-event, control-flow, false-positive] technologies: [al] countries: [w1] application-area: [all] @@ -17,10 +17,10 @@ A routine that raises an `OnBefore…` integration event with a `var IsHandled: Reset `IsHandled := false;` before a raise only when the value might otherwise carry over as `true`: the same variable is reused after an earlier raise without a control-flow proof that it is false, a raise is re-entered by a loop, the value comes from an input parameter, field, or global, or earlier code seeds it. Prefer separate fresh locals when independent event seams need independent handled state. A reset on a guaranteed-false fresh local used by one non-looping raise, or before a later raise reached only after a semantically valid `if IsHandled then exit;`, can be retained for readability, but its absence is not a correctness finding. -See sample: `initialize-ishandled-to-false-before-publishing.good.al`. +See sample: `reset-ishandled-only-when-the-value-can-carry-over.good.al`. ## Anti Pattern Raising `OnBeforeX(…, IsHandled)` when the variable can still be `true` from an earlier raise, an earlier loop iteration, or another source, so the publisher call starts with stale state. Do not match a single non-looping raise using a fresh local Boolean, or a later raise reached only after a semantically valid `if IsHandled then exit;` proves the value is false. -See sample: `initialize-ishandled-to-false-before-publishing.bad.al`. +See sample: `reset-ishandled-only-when-the-value-can-carry-over.bad.al`. diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index ebd2976..c854f1c 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-review.md @@ -51,7 +51,7 @@ When the post-conflict worklist is empty because no applicable events knowledge The following targeted checks map diff signals to specific `events` articles. Treat each as a candidate-selection cue: when the signal appears in the changed code, add the named article to the worklist and evaluate it in Action. -- An `IsHandled` value that can carry over as `true` (reused after an earlier raise, re-entered on a later loop iteration, input/global/field, or otherwise seeded) is passed to a publisher without a reset — `initialize-ishandled-to-false-before-publishing`. Do not match one non-looping raise using a fresh local Boolean, or a later raise reached only after a semantically valid `if IsHandled then exit;` proves the value is false. +- An `IsHandled` value that can carry over as `true` (reused after an earlier raise, re-entered on a later loop iteration, input/global/field, or otherwise seeded) is passed to a publisher without a reset — `reset-ishandled-only-when-the-value-can-carry-over`. Do not match one non-looping raise using a fresh local Boolean, or a later raise reached only after a semantically valid `if IsHandled then exit;` proves the value is false. - `if IsHandled then exit;` in a routine that also raises a paired `OnAfter…` event later, so the after-event is skipped whenever the call is handled — `preserve-onafter-execution-when-ishandled-skips-the-body`. - Any parameter added to a public Business/Integration event procedure, regardless of position; do not flag additions or reordering on `local`/`internal` publishers merely because a new parameter was not appended — `add-new-event-parameters-at-the-end`. - A shipped Business/Integration event renamed or removed, or an existing parameter renamed, removed, retyped, or changed to/from `var`, based on the mistaken assumption that `local` or `internal` prevents dependent subscription; parameter order alone is not a subscriber-contract violation — `treat-local-and-internal-events-as-subscriber-contracts`. From f027e28f8344592447e8b7b69ed5ae4778b9225f Mon Sep 17 00:00:00 2001 From: Wael <38723677+WaelAbuSeada@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:16:30 -0600 Subject: [PATCH 52/86] knowledge: improve review precision from BCApps PR 10080 feedback (#128) * knowledge: improve review precision from BCApps PR 10080 feedback * Update microsoft/knowledge/appsource/object-affixes-prevent-collisions.md Co-authored-by: Natalie Karolak, MVP <34504100+NKarolak@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Natalie Karolak, MVP <34504100+NKarolak@users.noreply.github.com> --- .../knowledge/appsource/object-affixes-prevent-collisions.md | 4 +++- microsoft/knowledge/error-handling/fielderror-vs-testfield.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md index a46c0d1..a3542a1 100644 --- a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md +++ b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: appsource -keywords: [object-affix, prefix, suffix, as0011, appsourcecop, collision, tableextension] +keywords: [object-affix, prefix, suffix, as0011, appsourcecop, collision, tableextension, first-party, isv] technologies: [al] countries: [w1] application-area: [all] @@ -15,6 +15,8 @@ An AppSource extension must prevent name collisions through its registered affix AppSourceCop enforces this. The primary rule is AS0011 ("An affix is required"); the affixes are configured through `mandatoryAffixes` (and `mandatoryPrefix`) in `AppSourceCop.json`. Two placements matter and are easy to get half-right: an object you define carries the affix at **object-name** level, while a member you add to a **standard** object carries the affix on that **member's** name. Adding an affixed object is not enough — an unaffixed field bolted onto `Customer` still collides and still fails validation. +This rule scopes to Marketplace ISV extensions, which is what AppSourceCop validates. A first-party Microsoft in-box module (publisher `Microsoft`, an object range reserved for first-party use, and no `AppSourceCop.json`/`mandatoryAffixes` in the app) is not built or shipped as an Marketplace extension and is not subject to AS0011, so an unaffixed action or field it adds to a base-application page is not a collision risk to flag. Renaming an existing shipped first-party member to add an affix is itself a breaking change to that module's own history and is not required by this rule. + ## Best Practice Own objects use the registered affix (for example `ABC Loyalty Tier`) or, when targeting BC23 or later, a qualifying namespace. Every field or action added to a standard object remains individually affixed (for example `Loyalty Points ABC` on a `Customer` tableextension). diff --git a/microsoft/knowledge/error-handling/fielderror-vs-testfield.md b/microsoft/knowledge/error-handling/fielderror-vs-testfield.md index 1353c03..9b58b32 100644 --- a/microsoft/knowledge/error-handling/fielderror-vs-testfield.md +++ b/microsoft/knowledge/error-handling/fielderror-vs-testfield.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: error-handling -keywords: [fielderror, testfield, field-validation, onvalidate, error-message, mandatory-field, record-context] +keywords: [fielderror, testfield, field-validation, onvalidate, error-message, mandatory-field, record-context, onaction, enabled-property] technologies: [al] countries: [w1] application-area: [all] @@ -14,6 +14,8 @@ application-area: [all] ## Best Practice Use `TestField` when the condition is a simple presence-or-equality check on a single field — mandatory-field gates and prerequisite checks at the top of a procedure read clearly and self-document intent. Use `FieldError` inside an `OnValidate` trigger or a validation procedure where surrounding business logic has already determined the value is invalid and you want a specific, custom message. Rely on the built-in field-and-record context both methods add rather than re-stating the field name in the text. +A page action's `OnAction` trigger is a different case: a page action is only invocable through its own UI control, so when the action's `Enabled` property is already bound to the same condition the trigger would otherwise `TestField`, the control cannot be clicked while the field is blank and the field can never reach the trigger empty. Adding a `TestField` there is redundant defensive code, not a missing check — flag it only when the trigger can run through a path `Enabled` does not cover (a shared procedure, an API, or a condition broader than what gates the action). + See sample: `fielderror-vs-testfield.good.al`. ## Anti Pattern From 35a7e72f124d6ab6d32003b1381397928ac9b050 Mon Sep 17 00:00:00 2001 From: Wenjie Fan <31087545+gggdttt@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:25:19 +0200 Subject: [PATCH 53/86] knowledge: three false-positive guards from BCApps PR 10277, 10278 and 10346 (#146) data-modeling: add insert-only-transfer-may-rely-on-caller-cleanup. A filter-and-insert transfer routine was reported for stale rows and duplicate keys even though the field OnValidate trigger calls a sibling cleanup procedure that clears the same range immediately before it. Deciding this requires reading the caller, so the article asks reviewers to trace call sites and keeps uncleared or mismatched-filter paths reportable. appsource: scope two-level-namespace-replaces-object-affix-not-extension-member-affix to apps that actually configure a mandatory affix. AS0011 only runs when AppSourceCop is enabled with a mandatory affix; a first-party in-box app that ships no such configuration is not subject to it. The member-affix requirement itself is unchanged for apps that do configure one. testing: allow permission-tests-must-lower-the-execution-context to accept a composed role. The article demanded the exact permission set under test be assigned directly, so a test that lowered permissions through a role including that set and then asserted WritePermission was false was reported as a coverage gap. What matters is the effective context plus a boundary assertion, not which object the test names. Co-authored-by: wenjiefan --- ...object-affix-not-extension-member-affix.md | 8 ++++--- ...nly-transfer-may-rely-on-caller-cleanup.md | 24 +++++++++++++++++++ ...-tests-must-lower-the-execution-context.md | 8 ++++--- 3 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 microsoft/knowledge/data-modeling/insert-only-transfer-may-rely-on-caller-cleanup.md diff --git a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md index 509fe6f..8e46049 100644 --- a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md +++ b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md @@ -1,7 +1,7 @@ --- bc-version: [23..] domain: appsource -keywords: [namespace, two-level, affix, prefix, suffix, as0011, tableextension, pageextension] +keywords: [namespace, two-level, affix, prefix, suffix, as0011, tableextension, pageextension, false-positive] technologies: [al] countries: [w1] application-area: [all] @@ -13,14 +13,16 @@ application-area: [all] Current AppSource naming guidance accepts a namespace with at least two levels, such as `Contoso.Rentals`, instead of a registered prefix or suffix on the names of objects the app owns. The namespace does not qualify members added to another publisher's object: fields, keys, controls, and actions introduced through table or page extensions still share the target object's flat member namespace and still need the registered affix. +The requirement comes from AppSourceCop rule AS0011, which only runs when the app enables AppSourceCop and configures a mandatory affix — normally an `AppSourceCop.json` next to the app manifest. An app that ships no such configuration is not subject to AS0011, and its extension members are not a compliance gap. This is the usual situation for first-party, in-box apps that ship as part of the product rather than through AppSource: their uniqueness comes from allocated object ID ranges and a controlled source tree, not from a registered affix. Confirm the extending app actually configures a mandatory affix before reporting an unaffixed extension member. + ## Best Practice -Choose one collision strategy for owned objects: a registered affix or a globally meaningful namespace with at least two levels. Regardless of that choice, apply the registered affix to every member added to a base or third-party object. Keep the affix configured for AppSourceCop so member validation remains deterministic. +Choose one collision strategy for owned objects: a registered affix or a globally meaningful namespace with at least two levels. Regardless of that choice, apply the registered affix to every member added to a base or third-party object. Keep the affix configured for AppSourceCop so member validation remains deterministic. Do not raise a missing member affix against an app that does not enable AppSourceCop with a mandatory affix; there AS0011 never fires, and the app's namespace is not the reason — the absent configuration is. See sample: `two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al`. ## Anti Pattern -Using `namespace Contoso;` as though one level satisfied the AppSource alternative, or declaring `namespace Contoso.Rentals;` and then adding an unaffixed `Loyalty Points` field to `Customer`. The namespace distinguishes the extension's own objects; it cannot disambiguate members on Customer. +Using `namespace Contoso;` as though one level satisfied the AppSource alternative, or declaring `namespace Contoso.Rentals;` and then adding an unaffixed `Loyalty Points` field to `Customer` in an app that does configure a mandatory affix. The namespace distinguishes the extension's own objects; it cannot disambiguate members on Customer. The mirror-image mistake is reporting an unaffixed extension member in an app that enables no mandatory affix at all — AS0011 does not apply there, and the finding is a false positive. See sample: `two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al`. diff --git a/microsoft/knowledge/data-modeling/insert-only-transfer-may-rely-on-caller-cleanup.md b/microsoft/knowledge/data-modeling/insert-only-transfer-may-rely-on-caller-cleanup.md new file mode 100644 index 0000000..be1b784 --- /dev/null +++ b/microsoft/knowledge/data-modeling/insert-only-transfer-may-rely-on-caller-cleanup.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [transfer, cleanup, deleteall, onvalidate, caller, stale-rows, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# An insert-only transfer routine may rely on cleanup its caller already performed + +## Description + +A routine that copies rows from a template table into a target table — a `TransferX` procedure filling comment, dimension, or attribute lines from a standard-task or template record — is frequently written as filter-and-insert with no `DeleteAll` of its own. That is not automatically a stale-row or duplicate-primary-key defect. In the common AL shape, the field's `OnValidate` trigger first calls a sibling cleanup procedure that clears the same filtered range, then calls the transfer. By the time `Insert` runs, the target range is guaranteed empty, so the transfer has nothing to clean up and adding a second `DeleteAll` inside it would be redundant. + +Deciding whether a missing cleanup is real therefore requires reading the caller, not just the routine in the diff. The relevant question is whether every path that reaches the transfer clears the target range first — not whether the transfer clears it itself. + +## Best Practice + +Before reporting a transfer or copy routine for missing cleanup, trace its call sites. If the callers in scope invoke a cleanup procedure that clears the same filtered range immediately beforehand — typically in the same `OnValidate` trigger or the same routine — the insert-only transfer is correct and must not be flagged for stale rows, duplicate keys, or a missing `DeleteAll`. Raise the finding only when a reachable call path inserts into a range that was not cleared, or when the cleanup filters a different range than the insert writes to. + +## Anti Pattern + +Reporting an insert-only transfer as a stale-row or duplicate-key risk on the strength of the routine body alone, when the trigger that calls it already ran the cleanup. The mirror-image mistake is waving through a transfer whose caller clears a *different* filter range than the one the transfer inserts into, or one reachable from a path with no cleanup at all — those are genuine defects. diff --git a/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md index 8e3e126..e53986b 100644 --- a/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md +++ b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: testing -keywords: [testpermissions, restrictive, disabled, permissions-mock, lower-permissions, super, permission-test] +keywords: [testpermissions, restrictive, disabled, permissions-mock, lower-permissions, super, permission-test, false-positive] technologies: [al] countries: [w1] application-area: [all] @@ -13,14 +13,16 @@ application-area: [all] `TestPermissions` describes how a test runner should establish the permission context; the enum value does not itself assign the business permission set being tested. `Restrictive` is the default and starts from D365 Full Access, requiring the test to lower permissions. `Disabled` leaves the test running as `SUPER`. A test that expects access to be denied while still running with either broad context can pass or fail for the wrong reason and never exercise the intended boundary. +What matters is the effective permission context at the moment the protected operation runs, not which permission set object the test names. A test may establish that context through a composed role that includes the permission set under test rather than applying that set directly — that mirrors how the permission set actually reaches a user in production, where roles are assigned and permission sets are included. Such a test is adequate when it proves the boundary it claims: for an indirect (lowercase `imd`) grant, asserting `WritePermission()` is false before invoking the mediating codeunit shows that no direct access was granted and that the subsequent write succeeded only through code. + ## Best Practice -Use `TestPermissions::Restrictive` for a permission-sensitive test and lower the current test user with the test framework's `"Permissions Mock"` or `"Library - Lower Permissions"` before invoking the protected operation. Assign the exact permission set the scenario claims to test and restore or stop the mock afterward. Use `Disabled` only for suites that do not assert permission behavior. +Use `TestPermissions::Restrictive` for a permission-sensitive test and lower the current test user with the test framework's `"Permissions Mock"` or `"Library - Lower Permissions"` before invoking the protected operation. Assign a permission context that actually contains the rights the scenario tests — either the permission set itself or a role that includes it — and restore or stop the mock afterward. Use `Disabled` only for suites that do not assert permission behavior, or where the test lowers the context explicitly through the test libraries instead of relying on the runner. Do not require a test to apply the permission set under test directly when it reaches the same rights through a composed role and then asserts the boundary. See sample: `permission-tests-must-lower-the-execution-context.good.al`. ## Anti Pattern -Setting `TestPermissions = Disabled` or leaving the effective D365 Full Access context in place while asserting that a limited user is denied, or adding a `[TestPermissions(...)]` attribute without any runner/test-library code that applies the intended permission set. +Setting `TestPermissions = Disabled` or leaving the effective D365 Full Access context in place while asserting that a limited user is denied, or adding a `[TestPermissions(...)]` attribute without any runner/test-library code that applies the intended permission set. Do not report the mirror image: a test that lowers the context through a role including the permission set under test, and then asserts the boundary, has exercised that permission set and is not a coverage gap. See sample: `permission-tests-must-lower-the-execution-context.bad.al`. From c213f1495ec10079f957fbb11032170b5a1ed174 Mon Sep 17 00:00:00 2001 From: wenjiefan Date: Wed, 2 Sep 2026 11:46:13 +0200 Subject: [PATCH 54/86] Exempt optional notification handlers from the HandlerFunctions execution rule The narrowed UI-handler guidance still stated the execution rule without the qualifier the linked Microsoft reference uses. The article said every listed handler must execute at least once, and the testing leaf skill asked for `[HandlerFunctions(...)]` to match the invoked handlers exactly. The reference says every *nonoptional* listed handler must execute, and that send-notification and recall-notification handlers can be optional. As written, an agent could flag a deliberately unused optional notification handler. The discriminator is narrower than the handler type. Both `[SendNotificationHandler([HandlerIsOptional: Boolean])]` and `[RecallNotificationHandler([HandlerIsOptional: Boolean])]` take an explicit optionality argument, so `[SendNotificationHandler(true)]` is exempt while the same attribute written without the argument stays nonoptional like every other handler type. Keying the exemption on the argument rather than the type keeps it checkable from the diff and avoids the opposite false positive, where an agent stops flagging genuinely nonoptional notification handlers. Changes: - The article now states the nonoptional qualifier, explains that optionality is declared rather than inferred, and adds an explicit do-not-flag clause. That clause also forbids proposing removal, because the listed entry is what keeps the test passing on the runs where the notification does fire. - The testing leaf skill carries the same boundary in its `ui-handlers-in-tests` cue, and its mechanical-fix list no longer allows removing a listed optional notification handler as a one-click suggestion. - `SendNotificationHandler` and `RecallNotificationHandler` were missing from the skill's testing token list, so notification handlers were not reliably surfaced to the relevance step at all. Both are now listed. - The good sample gains a test that lists an unreached `[SendNotificationHandler(true)]`; the bad sample gains the mirror image, an unreached `[SendNotificationHandler]` with no optionality argument. The pair differs only by that argument, which is the point. - `evaluation/review-fixtures.json` pins the testing domain to `ui-handlers-in-tests` so the boundary is exercised: the good sample is the clean control at `minimumCleanRate` 1.0 and the bad sample is the expected finding. Keywords were retagged with `notification` and `optional-handler`. validate_frontmatter.py reports 0 errors; Test-ReviewFixtures.ps1 passes with 32 cases across 16 leaf domains and resolves the testing fixture to this article. --- evaluation/review-fixtures.json | 3 +++ .../testing/ui-handlers-in-tests.bad.al | 17 ++++++++++++++++ .../testing/ui-handlers-in-tests.good.al | 20 +++++++++++++++++++ .../knowledge/testing/ui-handlers-in-tests.md | 10 ++++++---- microsoft/skills/review/al-testing-review.md | 6 +++--- 5 files changed, 49 insertions(+), 7 deletions(-) diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 68a646d..62fcf19 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -28,6 +28,9 @@ "telemetry": { "article": "telemetry-event-id-stable-unique" }, + "testing": { + "article": "ui-handlers-in-tests" + }, "upgrade": { "article": "initvalue-does-not-update-existing-rows", "context": "The extended table existed in the previous app version and already contains rows." diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al b/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al index d0832fe..47743e0 100644 --- a/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al +++ b/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al @@ -40,6 +40,17 @@ codeunit 50401 "Test UI Handler Proof Bad" Page.RunModal(Page::"Customer Card", Customer); end; + [Test] + [HandlerFunctions('CustomerCardHandler,MandatoryNotificationHandler')] + procedure UnreachedNonoptionalNotificationHandlerFailsAtRuntime() + var + Customer: Record Customer; + begin + LibrarySales.CreateCustomer(Customer); + + Page.RunModal(Page::"Customer Card", Customer); + end; + [ModalPageHandler] procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card") begin @@ -51,6 +62,12 @@ codeunit 50401 "Test UI Handler Proof Bad" Reply := true; end; + [SendNotificationHandler] + procedure MandatoryNotificationHandler(var TheNotification: Notification): Boolean + begin + exit(true); + end; + var Assert: Codeunit "Library Assert"; LibrarySales: Codeunit "Library - Sales"; diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.good.al b/microsoft/knowledge/testing/ui-handlers-in-tests.good.al index fafc515..753873f 100644 --- a/microsoft/knowledge/testing/ui-handlers-in-tests.good.al +++ b/microsoft/knowledge/testing/ui-handlers-in-tests.good.al @@ -16,12 +16,32 @@ codeunit 50400 "Test UI Handler Capture Good" Assert.AreEqual(Customer."No.", CapturedCustomerNo, 'The customer card opened for the wrong customer.'); end; + [Test] + [HandlerFunctions('CustomerCardHandler,CreditLimitNotificationHandler')] + procedure CustomerCardOpensForCustomerWithinCreditLimit() + var + Customer: Record Customer; + begin + LibrarySales.CreateCustomer(Customer); + CapturedCustomerNo := ''; + + Page.RunModal(Page::"Customer Card", Customer); + + Assert.AreEqual(Customer."No.", CapturedCustomerNo, 'The customer card opened for the wrong customer.'); + end; + [ModalPageHandler] procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card") begin CapturedCustomerNo := CustomerCard."No.".Value(); end; + [SendNotificationHandler(true)] + procedure CreditLimitNotificationHandler(var CreditLimitNotification: Notification): Boolean + begin + exit(true); + end; + var Assert: Codeunit "Library Assert"; LibrarySales: Codeunit "Library - Sales"; diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.md b/microsoft/knowledge/testing/ui-handlers-in-tests.md index 42a8d83..d451301 100644 --- a/microsoft/knowledge/testing/ui-handlers-in-tests.md +++ b/microsoft/knowledge/testing/ui-handlers-in-tests.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: testing -keywords: [handler, handlerfunctions, confirm, message, strmenu, variable-storage, enqueue, capture, runmodal, unhandled-ui] +keywords: [handler, handlerfunctions, confirm, message, notification, optional-handler, enqueue, capture, runmodal, unhandled-ui] technologies: [al] countries: [w1] application-area: [all] @@ -11,18 +11,20 @@ application-area: [all] ## Description -A test runs headless, so every UI call on the executed path must be intercepted by a matching handler named in `[HandlerFunctions(...)]`. The list is a two-sided contract: an unhandled UI call aborts the test, while Microsoft documents that [every listed handler must execute at least once](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/attributes/devenv-handlerfunctions-attribute#remarks) or the test fails. +A test runs headless, so every UI call on the executed path must be intercepted by a matching handler named in `[HandlerFunctions(...)]`. The list is a two-sided contract: an unhandled UI call aborts the test, while Microsoft documents that [every nonoptional listed handler must execute at least once](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/attributes/devenv-handlerfunctions-attribute#remarks) or the test fails. + +Optionality is declared, not inferred. `SendNotificationHandler` and `RecallNotificationHandler` accept a `HandlerIsOptional` argument, so `[SendNotificationHandler(true)]` may stay listed on a run that never raises the notification, while the same attribute written without that argument is nonoptional like every other handler type. Notifications are conditional by nature, so an optional notification handler is listed precisely because the scenario may or may not reach it. Beyond that wiring guarantee, the test must verify the behavior it cares about. The appropriate pattern depends on the contract: a handler can capture concrete page state or a result and the test can assert that semantic postcondition after `RunModal`; assertions inside a handler are also supported. Queue/enqueue/dequeue and `LibraryVariableStorage.AssertEmpty` are useful when interaction order, count, text, replies, or a scripted sequence is itself part of the contract, but they are not mandatory for every handler. ## Best Practice -List precisely the handlers the scenario triggers and make each handler contribute meaningful evidence. For a single modal page, reset a capture variable before the action, capture a concrete value from the page in the handler, and assert the expected value after `RunModal`. For ordered or repeated interactions, let the test enqueue expectations, let handlers dequeue and verify them, clear storage during initialization, and finish with `AssertEmpty`. +List the handlers the scenario triggers, keep an optional notification handler listed for a notification the scenario may conditionally raise, and make each executed handler contribute meaningful evidence. For a single modal page, reset a capture variable before the action, capture a concrete value from the page in the handler, and assert the expected value after `RunModal`. For ordered or repeated interactions, let the test enqueue expectations, let handlers dequeue and verify them, clear storage during initialization, and finish with `AssertEmpty`. See sample: `ui-handlers-in-tests.good.al`. ## Anti Pattern -Omitting a handler for a UI call, listing a handler the path never reaches, or claiming action success from a Boolean set before the action runs. A handler that only closes a page can also leave the test without a semantic assertion. Do not flag the absence of queue storage by itself; require it only when the test needs to prove interaction order, count, text, replies, or a scripted sequence. +Omitting a handler for a UI call, listing a nonoptional handler the path never reaches, or claiming action success from a Boolean set before the action runs. A handler that only closes a page can also leave the test without a semantic assertion. Do not flag the absence of queue storage by itself; require it only when the test needs to prove interaction order, count, text, replies, or a scripted sequence. Do not flag a listed `[SendNotificationHandler(true)]` or `[RecallNotificationHandler(true)]` that the run does not reach, and never propose removing one: the entry is what keeps the test passing on the runs where the notification does fire. See sample: `ui-handlers-in-tests.bad.al`. diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index fb5d50f..8bad734 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-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 codeunits with `Subtype = Test`, test runner codeunits with `TestIsolation`, test libraries, and codeunits that define UI handlers. - The changed methods and attributes, weighted toward `[Test]`, `[TransactionModel(...)]`, `[TestPermissions(...)]`, `[HandlerFunctions(...)]`, handler attributes, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, fixture initialization, and test-library calls. -- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `Subtype = TestRunner`, `TestIsolation`, `TestPermissions`, `Restrictive`, `NonRestrictive`, `Disabled`, `Permissions Mock`, `Library - Lower Permissions`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Init`, `Insert`). +- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `Subtype = TestRunner`, `TestIsolation`, `TestPermissions`, `Restrictive`, `NonRestrictive`, `Disabled`, `Permissions Mock`, `Library - Lower Permissions`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `SendNotificationHandler`, `RecallNotificationHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Init`, `Insert`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no testing-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. @@ -50,7 +50,7 @@ The following targeted checks cover every current `testing` article. Treat each - A permission-sensitive test uses `TestPermissions = Disabled`, claims to test a restricted user without `"Permissions Mock"`/`"Library - Lower Permissions"`, or declares `[TestPermissions(...)]` without applying that context — `permission-tests-must-lower-the-execution-context`. - Test fixture code manually calls `Init`/`Insert`, invents keys or prerequisite records, or bypasses available `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, or equivalent library codeunits — `use-library-codeunits-for-test-fixtures`. - `asserterror` is added or changed without a following `Assert.ExpectedError`, `Assert.ExpectedErrorCode`, or a purpose-built assertion such as `ExpectedTestFieldError` — `asserterror-needs-expectederror-and-code`. -- A test path raises UI and `[HandlerFunctions(...)]` does not match the invoked handlers, or the test has no meaningful evidence of the UI result (for example, it treats a Boolean set before the action as proof of success) — `ui-handlers-in-tests`. A capture/reset/assert-after-`RunModal` pattern is valid. Enqueue/dequeue and `AssertEmpty` are required only when order, count, text, replies, or a scripted sequence is part of the contract. +- A test path raises UI and `[HandlerFunctions(...)]` does not match the invoked handlers, or the test has no meaningful evidence of the UI result (for example, it treats a Boolean set before the action as proof of success) — `ui-handlers-in-tests`. A capture/reset/assert-after-`RunModal` pattern is valid. Enqueue/dequeue and `AssertEmpty` are required only when order, count, text, replies, or a scripted sequence is part of the contract. Only nonoptional handlers have to execute: a listed handler declared `[SendNotificationHandler(true)]` or `[RecallNotificationHandler(true)]` is optional by design, so do not treat it as unmatched when the run never raises the notification. 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`. @@ -74,7 +74,7 @@ Set `confidence` to: After evaluating each worklist entry, also consider whether the diff exhibits a testing defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material testing defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly AL testing; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. -For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add the matching `ExpectedError` assertion after `asserterror`; add or remove a handler name in `HandlerFunctions`; add `LibraryVariableStorage.Clear` or `AssertEmpty` when queue/LVS intentionally verifies interaction order, count, text, replies, or a scripted sequence; or replace hand-rolled fixture creation with an evident library call). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add the matching `ExpectedError` assertion after `asserterror`; add or remove a handler name in `HandlerFunctions`, except that a listed optional notification handler must never be proposed for removal; add `LibraryVariableStorage.Clear` or `AssertEmpty` when queue/LVS intentionally verifies interaction order, count, text, replies, or a scripted sequence; or replace hand-rolled fixture creation with an evident library call). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. From 3e848d1ec2a25a36249015673911827cfefd036f Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Wed, 2 Sep 2026 14:44:09 +0200 Subject: [PATCH 55/86] knowledge(performance): align SetLoadFields placement with AL Guidelines (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * knowledge(performance): align SetLoadFields placement with AL Guidelines (#120) The AL Guidelines mark `SetLoadFields` placed before `SetRange`/`SetFilter` as bad code and recommend filters first, while the BCQuality samples used the opposite order — contradictory guidance across two Microsoft repos. Per Learn (`Record.SetLoadFields`), "fields that are filtered upon are always loaded", so the two orders produce an identical projection. The upstream rule is a readability convention: keep `SetLoadFields` adjacent to the read it governs. - Reorder filters ahead of `SetLoadFields` in the six affected AL samples. - State the placement convention in the Best Practice section. - Record in Description that order does not change the projection, and that only a fieldless `SetLoadFields()` or a later overwriting call does. - Add an Anti Pattern note so review agents treat the reverse order as a readability observation, never a performance defect. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Clarify partial-record projection changes Document AddLoadFields, SetBaseLoadFields, and Reset alongside SetLoadFields so the statement-order guidance does not imply those APIs leave the projection unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89dba8c8-6529-4b60-956f-875a59be499d --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89dba8c8-6529-4b60-956f-875a59be499d --- ...d-cloning-records-before-modify-delete-in-loops.bad.al | 2 +- ...-cloning-records-before-modify-delete-in-loops.good.al | 2 +- ...oad-only-primary-key-fields-for-reference-work.good.al | 2 +- .../use-setautocalcfields-for-per-row-flowfields.bad.al | 2 +- .../use-setautocalcfields-for-per-row-flowfields.good.al | 2 +- .../use-setloadfields-for-partial-records.good.al | 2 +- .../performance/use-setloadfields-for-partial-records.md | 8 +++++--- 7 files changed, 11 insertions(+), 9 deletions(-) diff --git a/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.bad.al b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.bad.al index 0a70e85..1faf1b7 100644 --- a/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.bad.al +++ b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.bad.al @@ -5,8 +5,8 @@ codeunit 50493 "Perf Record Clone Bad" Customer: Record Customer; CustomerCopy: Record Customer; begin - Customer.SetLoadFields("Credit Limit (LCY)"); Customer.SetFilter("Credit Limit (LCY)", '>0'); + Customer.SetLoadFields("Credit Limit (LCY)"); if Customer.FindSet(true) then repeat CustomerCopy.Copy(Customer); diff --git a/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.good.al b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.good.al index 84bfda4..16e1160 100644 --- a/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.good.al +++ b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.good.al @@ -4,8 +4,8 @@ codeunit 50492 "Perf Record Clone Good" var Customer: Record Customer; begin - Customer.SetLoadFields("Credit Limit (LCY)"); Customer.SetFilter("Credit Limit (LCY)", '>0'); + Customer.SetLoadFields("Credit Limit (LCY)"); if Customer.FindSet(true) then repeat Customer.Validate( diff --git a/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.good.al b/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.good.al index 5dcc499..7437a4b 100644 --- a/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.good.al +++ b/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.good.al @@ -6,8 +6,8 @@ codeunit 50100 "Item Reindex Queue" ReindexQueue: Codeunit "Reindex Queue"; begin // Only the primary key is used in the loop body; load nothing else. - Item.SetLoadFields("No."); Item.SetRange("Item Category Code", CategoryCode); + Item.SetLoadFields("No."); if Item.FindSet() then repeat diff --git a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.bad.al b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.bad.al index 86b1842..42779b4 100644 --- a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.bad.al +++ b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.bad.al @@ -4,8 +4,8 @@ codeunit 50491 "Perf AutoCalcFields Bad" var Customer: Record Customer; begin - Customer.SetLoadFields("Credit Limit (LCY)"); Customer.SetFilter("Credit Limit (LCY)", '>0'); + Customer.SetLoadFields("Credit Limit (LCY)"); if Customer.FindSet() then repeat Customer.CalcFields("Balance (LCY)"); diff --git a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.good.al b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.good.al index 072321c..3c1208f 100644 --- a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.good.al +++ b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.good.al @@ -4,8 +4,8 @@ codeunit 50490 "Perf AutoCalcFields Good" var Customer: Record Customer; begin - Customer.SetLoadFields("Credit Limit (LCY)"); Customer.SetFilter("Credit Limit (LCY)", '>0'); + Customer.SetLoadFields("Credit Limit (LCY)"); Customer.SetAutoCalcFields("Balance (LCY)"); if Customer.FindSet() then repeat 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 772023c..a5bee5f 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al @@ -4,8 +4,8 @@ codeunit 50218 "Perf Sample LoadFields Good" var Customer: Record Customer; begin - Customer.SetLoadFields(Name); Customer.SetRange("Country/Region Code", 'US'); + Customer.SetLoadFields(Name); if Customer.FindSet() then repeat Message(Customer.Name); diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md index 85d20b3..a8d134f 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: performance -keywords: [setloadfields, partial-record, normal-field, flowfield, get, findset] +keywords: [setloadfields, partial-record, normal-field, flowfield, get, findset, statement-order] technologies: [al] countries: [w1] application-area: [all] @@ -11,11 +11,11 @@ application-area: [all] ## Description -`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. +`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. Its position relative to `SetRange`/`SetFilter` does not change the projection: filtered fields are added to the load set at read time either way. Projection-changing operations are separate: `AddLoadFields(...)` expands the selection, a later `SetLoadFields(...)` or `SetBaseLoadFields()` overwrites it, and `Reset()` or a fieldless `SetLoadFields()` restores all readable normal fields. ## Best Practice -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`). +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. Place the call immediately before the read, after any `SetRange`/`SetFilter`, so a reader can see at a glance which read the selection governs and any projection-changing operation is easy to spot. 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`. @@ -23,4 +23,6 @@ See sample: `use-setloadfields-for-partial-records.good.al`. 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. +Statement order is not part of this anti pattern. `SetLoadFields` placed ahead of `SetRange`/`SetFilter` materializes exactly the same columns as the reverse order, so a reviewer reports it as a readability observation at most — never as a performance defect. + See sample: `use-setloadfields-for-partial-records.bad.al`. From c39f723caedbdf622c9da6cdcb664b28f3ee2f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Silva?= <50581065+aacnsilva@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:48:33 +0100 Subject: [PATCH 56/86] Add community knowledge: AL boolean operators do not short-circuit (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add community knowledge on AL boolean operators not short-circuiting AL gives no short-circuit (lazy) evaluation guarantee for and/or/xor — neither the AL operators nor the boolean operators documentation defines a lazy evaluation order. LLMs trained on C#, JavaScript, or SQL assume the left operand guards the right, which produces conditions where a guard does not protect an unsafe subscript or a field read after a failed Get, and where an expensive operand is paid on every path. Adds community/knowledge/performance/boolean-operators-do-not-short-circuit.md with good/bad AL companions. The guidance prefers nested if when one operand depends on another, while keeping and/or legitimate for operands that are independently safe and cheap, so a reviewer does not flag harmless bound checks. This is the first article in a community performance domain; the Microsoft performance review leaf skill already sources candidates by domain across every enabled layer, so no skill change is needed to reach it. Co-Authored-By: Claude Opus 5 * Add case true of pattern for long condition chains Follow-up from PR review: nested if is the right answer for two or three dependent conditions, but past that the nesting becomes the problem. AL's case statement is the flat alternative — the control statements documentation states a value set "must be an expression or a range" and that the first matching value set executes, so case true of / case false of accept boolean expressions and stop at the first match. That is the laziness the boolean operators do not provide. Adds case-true-of-for-long-condition-chains.md with good/bad AL companions: case false of for guard chains where every condition must hold, case true of for first-match dispatch. The bad sample shows both failure shapes — a five-level if ladder, and the worse escape of collapsing it into an and chain, which trades nesting for a real defect. Cross-links both articles, and adds the threshold to the short-circuit article's Best Practice so following it does not lead to a deep ladder. Co-Authored-By: Claude Opus 5 * Comma-group the pure value sets in the case false of sample Review feedback: the five value sets sharing exit(false) should be comma separated. Applied to the three that are pure field reads with no order dependency. The Get and the Blocked read keep their own value sets. The documentation guarantees that the first matching value set executes, which orders matching across separate value sets; it says nothing about evaluation within one comma-separated set, and the natural lowering of that is an equality-or chain — where AL's or does not short-circuit. Grouping the Get with the checks that must precede it would rest the sample's correctness on undocumented behaviour, which is the defect these two articles exist to prevent. Encodes the boundary in the Best Practice section so the grouping is applied where it is safe and not where it is not, and scopes the repeated-exit detection signal explicitly to nested chains so the case sample does not read as its own anti-pattern. Co-Authored-By: Claude Opus 5 * Use a single exit(false) for the whole case false of chain Review decision: all five conditions share one action, so they share one comma-separated value set with a single exit(false). Aligns the article's Best Practice with the sample — it previously told authors to keep side-effecting conditions in their own value set, which the sample no longer does — and drops the now-contradictory wording about listing the failure action per condition. Co-Authored-By: Claude Opus 5 * Drop the parentheses from the case false of value sets Applies NKarolak's review suggestion: a case value set needs no parentheses around a comparison. Encodes the rationale in the Best Practice section, since it is a real advantage of the pattern and is not documented elsewhere in the repo. The AL operator hierarchy places and/xor above the comparison operators and or just above them too, so parentheses are mandatory in an and chain — A = B and C = D misparses without them — while a case value set has no and to bind tighter and needs none. That inverts the precedence most developers arrive with from C#. Also notes in the bad sample that its parentheses are not optional, so the two samples contrast on parentheses as well as on evaluation. Co-Authored-By: Claude Opus 5 * Address correctness review: or/xor pattern, case value-set ordering Fixes two points from JesperSchulz's review on PR #136. 1. boolean-operators-do-not-short-circuit.md gave one fix — nested if — for and, or, and xor alike. That's only correct for and: nesting if A then if B then Action drops the A-true/B-false case of A or B, silently changing the result. Gives or its own early-exit pattern (if A then exit(true); exit(B)), warns explicitly against applying the and-rewrite to or, and clarifies that xor is not a short-circuit candidate in any language since its result always depends on both operands. Adds an or fixture (IsEligibleForFreeShipping) to both samples so an agent has a concrete pattern to match instead of extrapolating from the and-only examples. 2. case-true-of-for-long-condition-chains.md derived stop-at-first-match for one comma-separated value set from the documentation's guarantee about the first matching value set — plural, i.e. ordering across value sets, which is not the same claim. The good sample's Item.Get / Blocked pair depended on the one the docs don't make. Restructures the sample to only comma-group the three pure, order-independent checks; Get and Blocked keep their own value sets, in order, relying solely on the guarantee that is actually documented. Description and Anti Pattern now state that boundary so it isn't re-collapsed later. Also carries forward a parenthesis fix (not Item.Blocked in the collapsed bad sample) that was made two commits ago but never landed. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- ...lean-operators-do-not-short-circuit.bad.al | 31 ++++++++++++ ...ean-operators-do-not-short-circuit.good.al | 41 ++++++++++++++++ .../boolean-operators-do-not-short-circuit.md | 36 ++++++++++++++ ...e-true-of-for-long-condition-chains.bad.al | 29 +++++++++++ ...-true-of-for-long-condition-chains.good.al | 48 +++++++++++++++++++ .../case-true-of-for-long-condition-chains.md | 30 ++++++++++++ 6 files changed, 215 insertions(+) create mode 100644 community/knowledge/performance/boolean-operators-do-not-short-circuit.bad.al create mode 100644 community/knowledge/performance/boolean-operators-do-not-short-circuit.good.al create mode 100644 community/knowledge/performance/boolean-operators-do-not-short-circuit.md create mode 100644 community/knowledge/performance/case-true-of-for-long-condition-chains.bad.al create mode 100644 community/knowledge/performance/case-true-of-for-long-condition-chains.good.al create mode 100644 community/knowledge/performance/case-true-of-for-long-condition-chains.md diff --git a/community/knowledge/performance/boolean-operators-do-not-short-circuit.bad.al b/community/knowledge/performance/boolean-operators-do-not-short-circuit.bad.al new file mode 100644 index 0000000..7fd2fc8 --- /dev/null +++ b/community/knowledge/performance/boolean-operators-do-not-short-circuit.bad.al @@ -0,0 +1,31 @@ +codeunit 50541 "Perf Sample NoShortCircuit Bad" +{ + procedure ExceedsThreshold(var Thresholds: array[10] of Decimal; Index: Integer; Amount: Decimal): Boolean + begin + // Thresholds[Index] is evaluated even when Index is 0, so the leading range + // check does not prevent the subscript from being read out of range. + exit((Index >= 1) and (Index <= ArrayLen(Thresholds)) and (Amount > Thresholds[Index])); + end; + + procedure IsBlockedCustomer(CustomerNo: Code[20]): Boolean + var + Customer: Record Customer; + begin + // The Get runs even for an empty CustomerNo, and Blocked is read even when the + // Get failed, so the result is taken from a record that was never loaded. + exit((CustomerNo <> '') and Customer.Get(CustomerNo) and (Customer.Blocked <> Customer.Blocked::" ")); + end; + + procedure IsEligibleForFreeShipping(SalesHeader: Record "Sales Header"): Boolean + begin + // HasActiveLoyaltyBenefit runs even when the amount alone already qualifies, + // paying for the costly check on every evaluation instead of only the path + // where it can still change the outcome. + exit((SalesHeader."Amount Including VAT" >= 1000) or HasActiveLoyaltyBenefit(SalesHeader."Sell-to Customer No.")); + end; + + local procedure HasActiveLoyaltyBenefit(CustomerNo: Code[20]): Boolean + begin + exit(CustomerNo <> ''); + end; +} diff --git a/community/knowledge/performance/boolean-operators-do-not-short-circuit.good.al b/community/knowledge/performance/boolean-operators-do-not-short-circuit.good.al new file mode 100644 index 0000000..0bfda10 --- /dev/null +++ b/community/knowledge/performance/boolean-operators-do-not-short-circuit.good.al @@ -0,0 +1,41 @@ +codeunit 50540 "Perf Sample NoShortCircuit Good" +{ + procedure ExceedsThreshold(var Thresholds: array[10] of Decimal; Index: Integer; Amount: Decimal): Boolean + begin + // 'and' is safe here: both operands are cheap and neither depends on the other. + if (Index >= 1) and (Index <= ArrayLen(Thresholds)) then + // The subscript lives in its own if, so it is never evaluated out of range. + if Amount > Thresholds[Index] then + exit(true); + exit(false); + end; + + procedure IsBlockedCustomer(CustomerNo: Code[20]): Boolean + var + Customer: Record Customer; + begin + // The cheap test runs first, and the field is read only after Get succeeded. + if CustomerNo = '' then + exit(false); + if not Customer.Get(CustomerNo) then + exit(false); + exit(Customer.Blocked <> Customer.Blocked::" "); + end; + + procedure IsEligibleForFreeShipping(SalesHeader: Record "Sales Header"): Boolean + begin + // 'or' is unsafe here: nesting would also be wrong, since it would drop the + // case where the amount alone already qualifies. Exit as soon as the cheap + // condition already decides the result; the costly lookup runs only on the + // path where it can still change the outcome. + if SalesHeader."Amount Including VAT" >= 1000 then + exit(true); + exit(HasActiveLoyaltyBenefit(SalesHeader."Sell-to Customer No.")); + end; + + local procedure HasActiveLoyaltyBenefit(CustomerNo: Code[20]): Boolean + begin + // Stands in for a costly check — a webservice call or a large table scan. + exit(CustomerNo <> ''); + end; +} diff --git a/community/knowledge/performance/boolean-operators-do-not-short-circuit.md b/community/knowledge/performance/boolean-operators-do-not-short-circuit.md new file mode 100644 index 0000000..7c4073b --- /dev/null +++ b/community/knowledge/performance/boolean-operators-do-not-short-circuit.md @@ -0,0 +1,36 @@ +--- +bc-version: [all] +domain: performance +keywords: [short-circuit, lazy-evaluation, boolean-operators, nested-if, guard, and-operator, or-operator, xor-operator, early-exit] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL boolean operators do not short-circuit + +## Description + +AL gives no short-circuit (lazy) evaluation guarantee for `and`, `or`, and `xor`: every operand of a boolean expression is evaluated, even when the leftmost operand already determines the result. Neither the AL operators documentation nor the boolean operators documentation defines a lazy evaluation order, so code must not depend on one. Developers arriving from C#, JavaScript, or SQL routinely assume the left operand guards the right; in AL it does not. `xor` is not actually a short-circuit candidate in any language — its result depends on both operands regardless of their values, so there is nothing to skip — but AL still evaluates both operands unconditionally, so neither should carry a cost or a risk the developer assumed the other would guard against. For `and` and `or`, the right operand still runs even when the left already decides the result, so its cost is paid on every evaluation, and a check intended to protect an unsafe expression — an array subscript, a division, a field read that is only valid after a successful `Get` — does not protect it. + +## Best Practice + +For an `and`-shaped guard — a condition that must hold before the next operand is safe or worth evaluating — split into nested `if` statements: the guarding or cheapest condition in the outer `if`, the dependent or expensive one in the inner `if`. This preserves the result, since `if A then if B then Action` matches `if A and B then Action` exactly. Where there is no `else` branch, nesting is a pure win; where there is one, extract the conditions into a helper procedure that exits early instead. + +For an `or`-shaped condition, do not nest: nesting `if A then if B then Action` drops the case where `A` is true and `B` is false, silently changing the result of `A or B`. Exit as soon as the cheap or safe operand already decides the outcome, and reach the other operand only on the path where it can still change the result — `if A then exit(true); exit(B);` for a boolean return, or `if A then Action else if B then Action;` when both branches share one action. + +`xor` has no equivalent rewrite, because its result always depends on both operands; the only actionable guidance is to keep both operands of an `xor` cheap and free of side effects, since AL evaluates both unconditionally. + +Where a chain of `and`-guards runs past about three conditions, stop nesting and use a `case` statement instead — see `case-true-of-for-long-condition-chains.md`. Keep `and` and `or` for operands that are independently safe and cheap — in-memory field comparisons, enum tests, bound checks — where combining them reads better and costs nothing. + +See sample: `boolean-operators-do-not-short-circuit.good.al`. + +## Anti Pattern + +A single condition that joins a guard with an operand depending on that guard, or with an expensive operand, using `and` or `or`. The consequence is either wasted work on every evaluation — a database call or validation procedure invoked even when the outcome is already decided — or a runtime error or silently wrong result that the guard was written to prevent. Applying the `and` fix to an `or` condition is a distinct mistake: rewriting `A or B` as nested `if`s drops the `A`-true/`B`-false case instead of preserving it. Detection signals: an operand that indexes an array or list with a variable whose bounds are checked in a sibling operand; `Record.Get(...)` or a `Find`/`IsEmpty` call as one operand of `and` with a field read of the same record as another; an expensive or unsafe operand combined with `or` next to a condition that alone already makes the result true; a boolean-returning procedure call combined with a cheap field test. The pattern is common in code ported from a language that does short-circuit, and in conditions grown by appending a clause to an existing `if`. + +See sample: `boolean-operators-do-not-short-circuit.bad.al`. + +## See also + +`case-true-of-for-long-condition-chains.md` covers what to do when nesting an `and`-guard chain would go more than about three levels deep. `microsoft/knowledge/performance/apply-guards-before-get.md` covers the related ordering rule for statements rather than operands. diff --git a/community/knowledge/performance/case-true-of-for-long-condition-chains.bad.al b/community/knowledge/performance/case-true-of-for-long-condition-chains.bad.al new file mode 100644 index 0000000..da6200b --- /dev/null +++ b/community/knowledge/performance/case-true-of-for-long-condition-chains.bad.al @@ -0,0 +1,29 @@ +codeunit 50543 "Perf Sample CaseChain Bad" +{ + procedure IsShippableLine(SalesLine: Record "Sales Line"): Boolean + var + Item: Record Item; + begin + // Five levels of nesting to sequence five guards. The evaluation order is + // carried by indentation alone and the body drifts steadily right. + if SalesLine.Type = SalesLine.Type::Item then + if SalesLine."No." <> '' then + if SalesLine."Qty. to Ship" > 0 then + if Item.Get(SalesLine."No.") then + if not Item.Blocked then + exit(true); + exit(false); + end; + + procedure IsShippableLineCollapsed(SalesLine: Record "Sales Line"): Boolean + var + Item: Record Item; + begin + // The wrong escape from the ladder: flattening it into 'and' trades the + // nesting for a defect, because every operand is still evaluated. Item + // fields are read even when the Get failed. The parentheses are not + // optional either — 'and' binds tighter than '=' and '<>' in AL. + exit((SalesLine.Type = SalesLine.Type::Item) and (SalesLine."No." <> '') and + (SalesLine."Qty. to Ship" > 0) and Item.Get(SalesLine."No.") and not Item.Blocked); + end; +} diff --git a/community/knowledge/performance/case-true-of-for-long-condition-chains.good.al b/community/knowledge/performance/case-true-of-for-long-condition-chains.good.al new file mode 100644 index 0000000..03497e8 --- /dev/null +++ b/community/knowledge/performance/case-true-of-for-long-condition-chains.good.al @@ -0,0 +1,48 @@ +codeunit 50542 "Perf Sample CaseChain Good" +{ + procedure IsShippableLine(SalesLine: Record "Sales Line"): Boolean + var + Item: Record Item; + begin + // 'case false of' matches value sets in order and stops at the first match. + // The first three checks are pure and order-independent, so they share one + // value set. Get and Blocked are each their own value set, in order, because + // the ordering the documentation guarantees is across value sets, not within + // one — Item.Get must run, and succeed, before Blocked is read. + case false of + SalesLine.Type = SalesLine.Type::Item, + SalesLine."No." <> '', + SalesLine."Qty. to Ship" > 0: + exit(false); + Item.Get(SalesLine."No."): + exit(false); + not Item.Blocked: + exit(false); + end; + exit(true); + end; + + procedure FindOpenDocumentType(CustomerNo: Code[20]): Text + begin + // 'case true of' stops at the first condition that holds, so the later + // lookups never run once an earlier one matched. + case true of + HasOpenDocument(CustomerNo, "Sales Document Type"::Quote): + exit('Quote'); + HasOpenDocument(CustomerNo, "Sales Document Type"::Order): + exit('Order'); + HasOpenDocument(CustomerNo, "Sales Document Type"::Invoice): + exit('Invoice'); + end; + exit('None'); + end; + + local procedure HasOpenDocument(CustomerNo: Code[20]; DocumentType: Enum "Sales Document Type"): Boolean + var + SalesHeader: Record "Sales Header"; + begin + SalesHeader.SetRange("Document Type", DocumentType); + SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); + exit(not SalesHeader.IsEmpty()); + end; +} diff --git a/community/knowledge/performance/case-true-of-for-long-condition-chains.md b/community/knowledge/performance/case-true-of-for-long-condition-chains.md new file mode 100644 index 0000000..1e0457e --- /dev/null +++ b/community/knowledge/performance/case-true-of-for-long-condition-chains.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: performance +keywords: [case-statement, case-true-of, nested-if, condition-chain, guard, lazy-evaluation, nesting-depth] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use case true of for long chains of dependent conditions + +## Description + +Because AL gives no short-circuit guarantee for `and` and `or`, a chain of conditions that must be evaluated in order has to be sequenced with nested `if` statements — and past three conditions the nesting itself becomes the problem: the body drifts right, the order of evaluation is carried by indentation alone, and any shared failure path is repeated at every level. AL's `case` statement is the flat alternative. Its value sets "must be an expression or a range", so `case true of` and `case false of` accept arbitrary boolean expressions, and the statement "is evaluated, and the first matching value set executes the associated statement" — evaluation stops at the first matching value set, which is exactly the laziness the boolean operators do not provide. That guarantee is stated for value sets, plural: it orders evaluation *across* separate value sets, and says nothing about the order of the individual expressions listed inside one comma-separated value set. + +## Best Practice + +Sequence two or three dependent conditions with nested `if`. Beyond that, switch to `case`: use `case false of` for a chain of guards where every condition must hold, letting control fall past `end` when all of them pass; use `case true of` for first-match dispatch, where each later probe runs only if the earlier ones did not match. Comma-separate conditions into one value set only when every one of them is a pure, order-independent test with no side effect — a field comparison, an enum check, a bound test — so it makes no difference whether AL evaluates all of them or stops early; grouping these costs nothing and removes the repeated action. A condition that guards another, or that carries a side effect or a cost of its own — a `Get`, a `Find`, a procedure call — keeps its own value set, placed immediately after the value set it depends on, so the code relies only on the ordering the documentation actually states. A value set needs no parentheses around a comparison, unlike an operand of `and` or `or`: the AL operator hierarchy places `and` and `or` above the comparison operators, so parentheses are mandatory there and the chain fills up with them. This keeps every condition at one indentation level, makes evaluation order explicit rather than implied by nesting, and preserves the stop-at-first-match behaviour it relies on. It also aligns with the AL programming convention that more than two alternatives belong in a `case` statement rather than an `if-then-else`. + +See sample: `case-true-of-for-long-condition-chains.good.al`. + +## Anti Pattern + +An `if` ladder four or more levels deep whose only purpose is sequencing guards. Detection: a chain of nested `if` statements with no `else`, each condition guarding the one below it, terminating in a single action or `exit`; or the same `exit`/`error` duplicated at every level of such a nested chain, purely to escape it. The second, worse form is collapsing that ladder into one `and` chain to escape the nesting — that trades indentation for a real defect, because the operands are still all evaluated. A third, subtler form is over-applying the comma-grouping itself: putting a guard and the condition it protects — for example `Item.Get(...)` and a read of a field on that same record — into one comma-separated value set. That relies on an evaluation order within a single value set that the documentation does not state; keep them in separate value sets instead. Reach for `case` over nested `if` or a collapsed `and` chain, and keep order-dependent conditions in their own value sets within it. + +See sample: `case-true-of-for-long-condition-chains.bad.al`. + +## See also + +`boolean-operators-do-not-short-circuit.md` covers the underlying evaluation rule that makes the sequencing necessary in the first place. From 7a41d25bbd2df541d38ec2ab84a8fc0c8f06a3b2 Mon Sep 17 00:00:00 2001 From: Kilian Seizinger <56249171+pri-kise@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:58:59 +0200 Subject: [PATCH 57/86] Process Context via manual event subscriber pattern (#145) * Process Context via manual event subscriber pattern * improve knowledge article --- ...ess-context-via-manually-bound-flag.bad.al | 72 +++++++++++++++++++ ...ss-context-via-manually-bound-flag.good.al | 70 ++++++++++++++++++ ...process-context-via-manually-bound-flag.md | 40 +++++++++++ 3 files changed, 182 insertions(+) create mode 100644 community/knowledge/events/expose-process-context-via-manually-bound-flag.bad.al create mode 100644 community/knowledge/events/expose-process-context-via-manually-bound-flag.good.al create mode 100644 community/knowledge/events/expose-process-context-via-manually-bound-flag.md diff --git a/community/knowledge/events/expose-process-context-via-manually-bound-flag.bad.al b/community/knowledge/events/expose-process-context-via-manually-bound-flag.bad.al new file mode 100644 index 0000000..ff2b277 --- /dev/null +++ b/community/knowledge/events/expose-process-context-via-manually-bound-flag.bad.al @@ -0,0 +1,72 @@ +// Demonstration-only AL. Not compiled by CI; illustrates the article. + +// Anti-pattern 1: the context is kept in single-instance state. +codeunit 50545 "Process State Bad Sample" +{ + SingleInstance = true; + + var + ProcessRunning: Boolean; + + procedure SetProcessRunning(NewProcessRunning: Boolean) + begin + ProcessRunning := NewProcessRunning; + end; + + procedure IsProcessRunning(): Boolean + begin + exit(ProcessRunning); + end; +} + +codeunit 50546 "Process Driver Bad Sample" +{ + procedure Run(DocumentNo: Code[20]) + var + ProcessState: Codeunit "Process State Bad Sample"; + begin + ProcessState.SetProcessRunning(true); + RunSharedCode(DocumentNo); + // An error above never reaches this line. The database writes roll + // back, the single-instance variable does not: ProcessRunning stays + // true until the company is closed, so every later run in this session + // is treated as part of the process. + ProcessState.SetProcessRunning(false); + end; + + local procedure RunSharedCode(DocumentNo: Code[20]) + begin + end; +} + +// Anti-pattern 2: the context stays private. Flag and driver look like the +// good sample, but the query is internal, so only the owning app can ever ask. +codeunit 50547 "Process Ctx Bad Sample" +{ + internal procedure IsProcessRunning(): Boolean + var + IsRunning: Boolean; + begin + OnCheckProcessRunning(IsRunning); + exit(IsRunning); + end; + + [InternalEvent(false)] + local procedure OnCheckProcessRunning(var IsRunning: Boolean) + begin + end; +} + +reportextension 50548 "Shared Report Ext Bad Sample" extends "Standard Sales - Invoice" +{ + trigger OnPreReport() + begin + // No callable query exists, so the extension infers the context from + // something it hopes only that process does - here, running without a + // UI. The guess is wrong for every other background run, and breaks + // silently the first time the owning app changes how it works. + if GuiAllowed() then + exit; + // ... behaviour that was meant to apply only inside that process ... + end; +} diff --git a/community/knowledge/events/expose-process-context-via-manually-bound-flag.good.al b/community/knowledge/events/expose-process-context-via-manually-bound-flag.good.al new file mode 100644 index 0000000..608a4e1 --- /dev/null +++ b/community/knowledge/events/expose-process-context-via-manually-bound-flag.good.al @@ -0,0 +1,70 @@ +// Demonstration-only AL. Not compiled by CI; illustrates the article. + +// The published context API - the entire public surface of the pattern. +// Any dependent extension may call IsProcessRunning; nothing else is exposed. +codeunit 50540 "Process Context Good Sample" +{ + procedure IsProcessRunning(): Boolean + var + IsRunning: Boolean; + begin + OnCheckProcessRunning(IsRunning); + exit(IsRunning); + end; + + // InternalEvent: only this app can subscribe, which is all the pattern + // needs. local: only this codeunit can raise it. + [InternalEvent(false)] + local procedure OnCheckProcessRunning(var IsRunning: Boolean) + begin + end; +} + +// The flag - implementation, not API, hence Access = Internal. It stores +// nothing between runs: being bound is the state. +codeunit 50541 "Process Flag Good Sample" +{ + Access = Internal; + EventSubscriberInstance = Manual; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Process Context Good Sample", 'OnCheckProcessRunning', '', false, false)] + local procedure SetProcessRunning(var IsRunning: Boolean) + begin + IsRunning := true; + end; +} + +// The app that drives the process claims the context for exactly its own run. +codeunit 50542 "Process Driver Good Sample" +{ + procedure Run(DocumentNo: Code[20]) + var + ProcessFlag: Codeunit "Process Flag Good Sample"; + begin + // A fresh instance, bound for exactly this call. If the shared code + // errors, the stack unwinds and takes the binding with it - nothing to reset. + BindSubscription(ProcessFlag); + RunSharedCode(DocumentNo); + end; + + local procedure RunSharedCode(DocumentNo: Code[20]) + begin + // A base application report, a posting routine, or any other object + // that extensions hook into - including a customer's own replacement. + end; +} + +// An extension hooked into that shared code can now ask the question directly +// instead of guessing which process is driving the run. The hook happens to be +// a report extension here; a subscriber on any other shared object is the same. +reportextension 50543 "Shared Report Ext Good Sample" extends "Standard Sales - Invoice" +{ + trigger OnPreReport() + var + ProcessContext: Codeunit "Process Context Good Sample"; + begin + if not ProcessContext.IsProcessRunning() then + exit; + // ... behaviour that applies only inside that process ... + end; +} diff --git a/community/knowledge/events/expose-process-context-via-manually-bound-flag.md b/community/knowledge/events/expose-process-context-via-manually-bound-flag.md new file mode 100644 index 0000000..a8e72be --- /dev/null +++ b/community/knowledge/events/expose-process-context-via-manually-bound-flag.md @@ -0,0 +1,40 @@ +--- +bc-version: [all] +domain: events +keywords: [bindsubscription, manual-binding, eventsubscriberinstance, internalevent, singleinstance, process-context, running-flag, scoped-state, rollback] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Expose process context through a manually bound flag, not a single-instance boolean + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +When an extension drives a process over shared code — a base application report, a posting routine — other extensions hooked into that code cannot tell whether a run belongs to that process: AL keeps no ambient "current process", so the driving app has to publish the context itself. The reflex answer, a `SingleInstance` codeunit holding a boolean set at the start of the run and cleared at the end, is unsafe: single-instance variables are not part of the database transaction, so a failed run rolls back the writes but not the flag, which stays `true` until the company is closed and marks every later run in the session as part of the process. A manual event binding carries the same signal safely, because the platform ties its lifetime to a variable's scope instead of to cleanup code that has to run. + +## Best Practice + +Publish the context as a query and let the binding itself be the state. One procedure is public; everything behind it is internal: + +- A public context codeunit exposes `IsProcessRunning(): Boolean`, which raises an `[InternalEvent]` publisher taking a `var Boolean` and returns what comes back — the entire public surface. The publisher is internal because only the owning app subscribes, `local` because only this codeunit raises it. +- A second codeunit, `Access = Internal` with `EventSubscriberInstance = Manual`, subscribes to that event and sets the boolean to `true`. Internal keeps it out of the API and stops other apps binding it to forge the context; it stores nothing between runs — being bound *is* the state. +- The driving process calls `BindSubscription` on a variable whose scope is exactly the span it wants to claim: a local in the procedure that drives the run, or a global on an object that lives exactly as long as the run. While that variable is alive the query answers `true`; when it leaves scope — normally, or because an error unwound the call stack — the platform removes the binding and the query answers `false` again. + +Bind a fresh instance per run rather than reusing one: the platform refuses to bind the same instance twice but accepts several instances of the same codeunit, so nesting and re-entrancy need no counter. The binding is session-scoped, so work the process starts in another session — a background session, a page background task, a job queue entry — cannot see it; pass the context explicitly there. + +See sample: `expose-process-context-via-manually-bound-flag.good.al`. + +## Anti Pattern + +Two shapes. + +First, the single-instance boolean — the failure described above. Detection: a `SingleInstance = true` codeunit with a boolean set before a process and cleared after it, read by other code to decide whether that process is running. + +Second, the context kept private: the driving app arranges its own marker — typically a manually bound subscriber on an event added for its benefit alone — and offers no query, or only an `internal` one. Other extensions are left inferring the context from side effects, request-page values, or record state, which breaks silently the first time the process changes. Detection: a manual binding used purely as an internal run marker, with no public query procedure over it. + +The mirror-image anti-pattern belongs to the reviewer: flagging the `BindSubscription` here as a leaked binding because no `UnbindSubscription` follows it. Scope release is the mechanism, not an omission — see `microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md`, whose leak case is an instance parked on a `SingleInstance` global that never leaves scope. + +See sample: `expose-process-context-via-manually-bound-flag.bad.al`. From 182180913e6c90b62e6c2a65f13371e53c7adb5e Mon Sep 17 00:00:00 2001 From: Kilian Seizinger <56249171+pri-kise@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:21:06 +0200 Subject: [PATCH 58/86] ShowMandatory + OnQueryClosePage Check (#147) * ShowMandatory + OnQueryClosePage Check * Tighten mandatory-field review guidance Require explicit ShowMandatory in the good sample, acknowledge that NotBlank marking is unreliable, and limit findings to visible editable controls on paths where users must supply a value. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89dba8c8-6529-4b60-956f-875a59be499d --------- Co-authored-by: Jesper Schulz-Wedde Copilot-Session: 89dba8c8-6529-4b60-956f-875a59be499d --- ...datory-on-code-required-page-fields.bad.al | 59 ++++++++++++ ...atory-on-code-required-page-fields.good.al | 61 ++++++++++++ ...wmandatory-on-code-required-page-fields.md | 32 +++++++ ...uest-page-input-in-onqueryclosepage.bad.al | 96 +++++++++++++++++++ ...est-page-input-in-onqueryclosepage.good.al | 62 ++++++++++++ ...-request-page-input-in-onqueryclosepage.md | 32 +++++++ 6 files changed, 342 insertions(+) create mode 100644 community/knowledge/ui/showmandatory-on-code-required-page-fields.bad.al create mode 100644 community/knowledge/ui/showmandatory-on-code-required-page-fields.good.al create mode 100644 community/knowledge/ui/showmandatory-on-code-required-page-fields.md create mode 100644 community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.bad.al create mode 100644 community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.good.al create mode 100644 community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md diff --git a/community/knowledge/ui/showmandatory-on-code-required-page-fields.bad.al b/community/knowledge/ui/showmandatory-on-code-required-page-fields.bad.al new file mode 100644 index 0000000..e7e8e26 --- /dev/null +++ b/community/knowledge/ui/showmandatory-on-code-required-page-fields.bad.al @@ -0,0 +1,59 @@ +table 50540 "Sample Shipping Agent Bad" +{ + fields + { + field(1; "Code"; Code[20]) + { + DataClassification = CustomerContent; + NotBlank = true; + } + field(2; Description; Text[100]) + { + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "Code") + { + Clustered = true; + } + } + + trigger OnInsert() + begin + TestField(Description); + end; +} + +page 50541 "Sample Shipping Agents Bad" +{ + PageType = List; + ApplicationArea = All; + UsageCategory = Lists; + SourceTable = "Sample Shipping Agent Bad"; + DelayedInsert = true; + + layout + { + area(content) + { + repeater(Agents) + { + field("Code"; Rec."Code") + { + ApplicationArea = All; + ToolTip = 'Specifies the code of the shipping agent.'; + } + field(Description; Rec.Description) + { + ApplicationArea = All; + ToolTip = 'Specifies a description of the shipping agent.'; + // Required by OnInsert, but nothing marks it. The user types + // the row, leaves it, and only then gets the error. + } + } + } + } +} diff --git a/community/knowledge/ui/showmandatory-on-code-required-page-fields.good.al b/community/knowledge/ui/showmandatory-on-code-required-page-fields.good.al new file mode 100644 index 0000000..0c63162 --- /dev/null +++ b/community/knowledge/ui/showmandatory-on-code-required-page-fields.good.al @@ -0,0 +1,61 @@ +table 50542 "Sample Shipping Agent" +{ + fields + { + field(1; "Code"; Code[20]) + { + DataClassification = CustomerContent; + NotBlank = true; + } + field(2; Description; Text[100]) + { + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "Code") + { + Clustered = true; + } + } + + trigger OnInsert() + begin + TestField(Description); + end; +} + +page 50543 "Sample Shipping Agents" +{ + PageType = List; + ApplicationArea = All; + UsageCategory = Lists; + SourceTable = "Sample Shipping Agent"; + DelayedInsert = true; + + layout + { + area(content) + { + repeater(Agents) + { + field("Code"; Rec."Code") + { + ApplicationArea = All; + ToolTip = 'Specifies the code of the shipping agent.'; + ShowMandatory = true; + } + field(Description; Rec.Description) + { + ApplicationArea = All; + ToolTip = 'Specifies a description of the shipping agent.'; + // Mirrors the TestField in OnInsert. ShowMandatory is what the + // client reads for the marker, so it has to be set here. + ShowMandatory = true; + } + } + } + } +} diff --git a/community/knowledge/ui/showmandatory-on-code-required-page-fields.md b/community/knowledge/ui/showmandatory-on-code-required-page-fields.md new file mode 100644 index 0000000..91fc65e --- /dev/null +++ b/community/knowledge/ui/showmandatory-on-code-required-page-fields.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: ui +keywords: [showmandatory, notblank, mandatory-field, red-asterisk, delayedinsert, testfield, page-field] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Mark code-required page fields with ShowMandatory + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`ShowMandatory` draws the red asterisk on a page field and, per the platform documentation, enforces no validation. The reverse is not reliable: code that enforces a value — `TestField` in `OnInsert`/`OnModify`, a `NotBlank` table field, a mandatory setup value — does not guarantee that the page field renders as mandatory. Because the two halves are independent, it is easy to ship a field that the code requires but the UI presents as optional. Microsoft documents that `NotBlank` can mark primary-key fields, but current client behavior does not do so consistently; on non-primary-key fields, a value that was never entered is not validated at all. `ShowMandatory` also overrides any marking `NotBlank` would contribute, so set it explicitly when the page must communicate a requirement. The gap is widest on a list page with `DelayedInsert = true`, where the enforcing error surfaces only when the user leaves the row — after the rest of the line is typed, with nothing having indicated which field was missing. + +## Best Practice + +Set `ShowMandatory = true` on every visible, editable page field whose value the user must supply before the record can be committed or an action can complete, and leave the enforcement in place: the property is presentation, `TestField`/`Error` is the guarantee, and the two belong together in the same change. When the requirement is conditional, bind `ShowMandatory` to a Boolean variable or field that mirrors the condition the enforcement checks — the base application drives `Vendor Invoice No.` on the Purchase Invoice page from an `Ext. Doc. No. Mandatory` setup flag this way. Two expression limits are worth knowing: the property cannot call an AL method, so compute the value into a variable first, and a numeric field that has a default value counts as filled, so it never shows the asterisk. See sample: `showmandatory-on-code-required-page-fields.good.al`. + +## Anti Pattern + +A required field with no mandatory marker: the table's `OnInsert` or the page's `OnInsertRecord` calls `TestField` on a field, or `NotBlank` is expected to force entry, while the page field bound to it carries no `ShowMandatory`. On a `DelayedInsert = true` list page the user fills the row, leaves it, and gets an error naming a field that never looked different from the optional ones. Reviewer signal: code on the relevant commit or action path requires the user to supply a field, the corresponding page control is visible and editable, and its `ShowMandatory` property is missing or does not mirror the same condition. A `TestField` or `Error` elsewhere in `OnValidate` or `OnModify` is not sufficient evidence: the field may be populated by code, non-editable, or required only for another path. Setting `ShowMandatory = false` on a field that is unconditionally required on the current path is the same defect stated explicitly, and per the documentation it also overrides any marking `NotBlank` would otherwise contribute. See sample: `showmandatory-on-code-required-page-fields.bad.al`. + +## See also + +`ShowMandatory` property — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-showmandatory-property + +`NotBlank` property — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-notblank-property + +Review finding this article generalizes — https://github.com/microsoft/BCApps/pull/9315#discussion_r3568817946 diff --git a/community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.bad.al b/community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.bad.al new file mode 100644 index 0000000..a74c3d0 --- /dev/null +++ b/community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.bad.al @@ -0,0 +1,96 @@ +report 50545 "Sample Statement Late Check" +{ + ApplicationArea = All; + UsageCategory = ReportsAndAnalysis; + Caption = 'Sample Statement Late Check'; + + dataset + { + dataitem(CustLedgerEntry; "Cust. Ledger Entry") + { + column(CustomerNo; "Customer No.") { } + column(Amount; Amount) { } + } + } + + requestpage + { + layout + { + area(content) + { + group(Options) + { + field(StatementDateField; StatementDate) + { + ApplicationArea = All; + Caption = 'Statement Date'; + ToolTip = 'Specifies the date the statement is printed for.'; + ShowMandatory = true; + } + } + } + } + // No OnQueryClosePage: nothing inspects the input while the page is open. + } + + var + StatementDate: Date; + StatementDateMissingErr: Label 'Enter a statement date.'; + + trigger OnPreReport() + begin + // The request page is already closed. The user cannot correct the date + // here — the run is aborted and every entry on the page is lost. + if StatementDate = 0D then + Error(StatementDateMissingErr); + end; +} + +report 50546 "Sample Statement Close Trap" +{ + ApplicationArea = All; + UsageCategory = ReportsAndAnalysis; + Caption = 'Sample Statement Close Trap'; + + dataset + { + dataitem(CustLedgerEntry; "Cust. Ledger Entry") + { + column(CustomerNo; "Customer No.") { } + column(Amount; Amount) { } + } + } + + requestpage + { + layout + { + area(content) + { + group(Options) + { + field(StatementDateField; StatementDate) + { + ApplicationArea = All; + Caption = 'Statement Date'; + ToolTip = 'Specifies the date the statement is printed for.'; + ShowMandatory = true; + } + } + } + } + + trigger OnQueryClosePage(CloseAction: Action): Boolean + begin + // No close-action guard. Cancel and Esc raise the error too, and an + // error prevents the page from closing — the user cannot get out. + if StatementDate = 0D then + Error(StatementDateMissingErr); + end; + } + + var + StatementDate: Date; + StatementDateMissingErr: Label 'Enter a statement date.'; +} diff --git a/community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.good.al b/community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.good.al new file mode 100644 index 0000000..4ad160a --- /dev/null +++ b/community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.good.al @@ -0,0 +1,62 @@ +report 50547 "Sample Statement Good" +{ + ApplicationArea = All; + UsageCategory = ReportsAndAnalysis; + Caption = 'Sample Statement Good'; + + dataset + { + dataitem(CustLedgerEntry; "Cust. Ledger Entry") + { + column(CustomerNo; "Customer No.") { } + column(Amount; Amount) { } + } + } + + requestpage + { + layout + { + area(content) + { + group(Options) + { + field(StatementDateField; StatementDate) + { + ApplicationArea = All; + Caption = 'Statement Date'; + ToolTip = 'Specifies the date the statement is printed for.'; + ShowMandatory = true; + } + } + } + } + + trigger OnQueryClosePage(CloseAction: Action): Boolean + begin + // Only when the user confirmed the run. Erroring on Cancel or Esc + // would trap the user in a page that refuses to close. The error + // itself keeps the page open, so the date can be fixed in place. + if CloseAction = Action::OK then + CheckStatementDate(); + end; + } + + var + StatementDate: Date; + StatementDateMissingErr: Label 'Enter a statement date.'; + + trigger OnPreReport() + begin + // The same check for runs that have no request page: job queue entries, + // Report.Run with the request window suppressed, scheduled and + // web-service invocations. + CheckStatementDate(); + end; + + local procedure CheckStatementDate() + begin + if StatementDate = 0D then + Error(StatementDateMissingErr); + end; +} diff --git a/community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md b/community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md new file mode 100644 index 0000000..75c8a09 --- /dev/null +++ b/community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: ui +keywords: [request-page, onqueryclosepage, onprereport, closeaction, mandatory-input, report-validation, job-queue] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Validate request-page input in OnQueryClosePage, not only in OnPreReport + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`OnPreReport` runs after the request page has closed and before the data items are processed. A validation error raised there aborts the run with the request page already gone: everything the user typed is lost, and the only way forward is to open the report again and retype it. The request page's own `OnQueryClosePage` trigger runs while the page is still open, and the platform does not close a page whose `OnQueryClosePage` raises an error or returns `false` — so the same check placed there leaves the user in front of their input, with the offending field still filled in and correctable. Moving the check rather than duplicating it fails the other way: a report can run with no request page at all — `Report.Run`/`Report.RunModal` with the request window suppressed, `UseRequestPage = false`, job queue entries, scheduled and web-service invocations — and `OnQueryClosePage` never fires on those paths. + +## Best Practice + +Put the validation in one local procedure and call it from both places: from the request page's `OnQueryClosePage`, so an interactive user can correct the input where they entered it, and from `OnPreReport` (or the relevant `OnPreDataItem`), so a run without a request page is still refused. Guard the interactive call on the close action — validate only when the user confirmed the run, for example `if CloseAction = Action::OK then`. The base application uses this shape; report 292, `Copy Sales Document`, validates its request-page input in `OnQueryClosePage` behind a close-action check. Mark the control with `ShowMandatory` as well, so the requirement is visible before the user submits — see `showmandatory-on-code-required-page-fields.md`. See sample: `validate-request-page-input-in-onqueryclosepage.good.al`. + +## Anti Pattern + +Validating mandatory request-page input only in `OnPreReport`. The check is correct and the report is never run with bad input, but every interactive mistake costs the user the whole request page: the error arrives after the page is gone, and filters, dates, and options all have to be entered again. Reviewer signal: a `TestField`, `Error`, or blank/zero-value check in `OnPreReport` or `OnPreDataItem` against a variable that is bound to a request-page control, in a report whose request page declares no `OnQueryClosePage`. + +The mirror defect is an `OnQueryClosePage` that validates without inspecting `CloseAction`: because an error prevents the page from closing, a user who presses Cancel or Esc to abandon the report is trapped in a request page that errors on every attempt to leave it. Validating only in `OnQueryClosePage` is the third variant — the interactive path behaves well, and a job queue entry runs the report with unchecked input. See sample: `validate-request-page-input-in-onqueryclosepage.bad.al`. + +## See also + +`OnQueryClosePage` (Request Page) trigger — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/requestpage/devenv-onqueryclosepage-requestpage-trigger + +`OnPreReport` (Report) trigger — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/report/devenv-onprereport-report-trigger From 53e2cf2fa4e582191efdc3effa6a935af864c08f Mon Sep 17 00:00:00 2001 From: Stefano Demiliani <33155438+demiliani@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:06:25 +0200 Subject: [PATCH 59/86] Add community guidance and review support for Business Central agents (#137) * feat(community/agents): add AL agent quality guidance - add 20 agent knowledge rules with good and bad AL samples - clarify setup dialog shape, temporary persistence, permissions, profiles, instructions, capability registration, and interface wiring - add the community-owned AL agents review skill - make review fixture discovery layer-aware with custom, community, and Microsoft precedence - document layer-aware evaluation behavior * fix(community/agents): align setup and permission samples - mark agent setup pages as non-extensible where required - narrow the agent profile by hiding an unrelated sales-order field - define a dedicated read-only permission set for the sales review agent - assign AL-defined permission sets with system scope and the owning app ID - clarify the permission scope guidance for default access controls * Address agent review feedback --- README.md | 2 +- ...permissions-intersect-with-assigner.bad.al | 11 ++ ...ermissions-intersect-with-assigner.good.al | 8 ++ ...ent-permissions-intersect-with-assigner.md | 30 ++++ .../agent-profile-narrows-visible-ui.bad.al | 8 ++ .../agent-profile-narrows-visible-ui.good.al | 26 ++++ .../agent-profile-narrows-visible-ui.md | 30 ++++ ...-setup-page-is-configuration-dialog.bad.al | 19 +++ ...setup-page-is-configuration-dialog.good.al | 30 ++++ ...gent-setup-page-is-configuration-dialog.md | 26 ++++ ...ent-setup-source-table-is-temporary.bad.al | 29 ++++ ...nt-setup-source-table-is-temporary.good.al | 68 +++++++++ .../agent-setup-source-table-is-temporary.md | 30 ++++ ...tup-table-keyed-by-user-security-id.bad.al | 24 ++++ ...up-table-keyed-by-user-security-id.good.al | 25 ++++ ...t-setup-table-keyed-by-user-security-id.md | 26 ++++ ...e-error-stops-warning-forces-review.bad.al | 8 ++ ...-error-stops-warning-forces-review.good.al | 41 ++++++ ...ssage-error-stops-warning-forces-review.md | 26 ++++ ...t-subscribers-only-in-agent-session.bad.al | 9 ++ ...-subscribers-only-in-agent-session.good.al | 34 +++++ ...agent-subscribers-only-in-agent-session.md | 26 ++++ ...pp-agent-calls-need-your-public-api.bad.al | 10 ++ ...p-agent-calls-need-your-public-api.good.al | 21 +++ ...ss-app-agent-calls-need-your-public-api.md | 26 ++++ ...ts-in-install-upgrade-or-background.bad.al | 19 +++ ...s-in-install-upgrade-or-background.good.al | 44 ++++++ ...agents-in-install-upgrade-or-background.md | 26 ++++ ...ult-access-controls-least-privilege.bad.al | 14 ++ ...lt-access-controls-least-privilege.good.al | 25 ++++ ...default-access-controls-least-privilege.md | 30 ++++ ...et-default-profile-lives-in-the-app.bad.al | 9 ++ ...t-default-profile-lives-in-the-app.good.al | 20 +++ .../get-default-profile-lives-in-the-app.md | 30 ++++ ...ction-structure-is-role-rules-steps.bad.al | 9 ++ ...tion-structure-is-role-rules-steps.good.al | 17 +++ ...struction-structure-is-role-rules-steps.md | 30 ++++ ...ructions-describe-work-not-tool-ids.bad.al | 7 + ...uctions-describe-work-not-tool-ids.good.al | 12 ++ ...instructions-describe-work-not-tool-ids.md | 30 ++++ ...ly-resource-instructions-on-upgrade.bad.al | 18 +++ ...y-resource-instructions-on-upgrade.good.al | 33 +++++ ...eapply-resource-instructions-on-upgrade.md | 26 ++++ ...er-copilot-capability-for-the-agent.bad.al | 21 +++ ...r-copilot-capability-for-the-agent.good.al | 26 ++++ ...gister-copilot-capability-for-the-agent.md | 30 ++++ .../set-instructions-as-secrettext.bad.al | 19 +++ .../set-instructions-as-secrettext.good.al | 20 +++ .../agents/set-instructions-as-secrettext.md | 26 ++++ ...te-agent-does-not-block-code-create.bad.al | 36 +++++ ...e-agent-does-not-block-code-create.good.al | 25 ++++ ...create-agent-does-not-block-code-create.md | 26 ++++ ...oming-review-only-for-trusted-input.bad.al | 15 ++ ...ming-review-only-for-trusted-input.good.al | 16 +++ ...-incoming-review-only-for-trusted-input.md | 26 ++++ ...use-documented-instruction-keywords.bad.al | 7 + ...se-documented-instruction-keywords.good.al | 13 ++ .../use-documented-instruction-keywords.md | 30 ++++ .../wire-all-three-agent-interfaces.bad.al | 9 ++ .../wire-all-three-agent-interfaces.good.al | 10 ++ .../agents/wire-all-three-agent-interfaces.md | 30 ++++ community/skills/review/al-agents-review.md | 70 +++++++++ evaluation/README.md | 6 +- evaluation/review-fixtures.json | 3 + tools/Test-ReviewFixtures.ps1 | 135 +++++++++++++----- 65 files changed, 1555 insertions(+), 36 deletions(-) create mode 100644 community/knowledge/agents/agent-permissions-intersect-with-assigner.bad.al create mode 100644 community/knowledge/agents/agent-permissions-intersect-with-assigner.good.al create mode 100644 community/knowledge/agents/agent-permissions-intersect-with-assigner.md create mode 100644 community/knowledge/agents/agent-profile-narrows-visible-ui.bad.al create mode 100644 community/knowledge/agents/agent-profile-narrows-visible-ui.good.al create mode 100644 community/knowledge/agents/agent-profile-narrows-visible-ui.md create mode 100644 community/knowledge/agents/agent-setup-page-is-configuration-dialog.bad.al create mode 100644 community/knowledge/agents/agent-setup-page-is-configuration-dialog.good.al create mode 100644 community/knowledge/agents/agent-setup-page-is-configuration-dialog.md create mode 100644 community/knowledge/agents/agent-setup-source-table-is-temporary.bad.al create mode 100644 community/knowledge/agents/agent-setup-source-table-is-temporary.good.al create mode 100644 community/knowledge/agents/agent-setup-source-table-is-temporary.md create mode 100644 community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.bad.al create mode 100644 community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.good.al create mode 100644 community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.md create mode 100644 community/knowledge/agents/analyze-message-error-stops-warning-forces-review.bad.al create mode 100644 community/knowledge/agents/analyze-message-error-stops-warning-forces-review.good.al create mode 100644 community/knowledge/agents/analyze-message-error-stops-warning-forces-review.md create mode 100644 community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.bad.al create mode 100644 community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.good.al create mode 100644 community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.md create mode 100644 community/knowledge/agents/cross-app-agent-calls-need-your-public-api.bad.al create mode 100644 community/knowledge/agents/cross-app-agent-calls-need-your-public-api.good.al create mode 100644 community/knowledge/agents/cross-app-agent-calls-need-your-public-api.md create mode 100644 community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.bad.al create mode 100644 community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.good.al create mode 100644 community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.md create mode 100644 community/knowledge/agents/get-default-access-controls-least-privilege.bad.al create mode 100644 community/knowledge/agents/get-default-access-controls-least-privilege.good.al create mode 100644 community/knowledge/agents/get-default-access-controls-least-privilege.md create mode 100644 community/knowledge/agents/get-default-profile-lives-in-the-app.bad.al create mode 100644 community/knowledge/agents/get-default-profile-lives-in-the-app.good.al create mode 100644 community/knowledge/agents/get-default-profile-lives-in-the-app.md create mode 100644 community/knowledge/agents/instruction-structure-is-role-rules-steps.bad.al create mode 100644 community/knowledge/agents/instruction-structure-is-role-rules-steps.good.al create mode 100644 community/knowledge/agents/instruction-structure-is-role-rules-steps.md create mode 100644 community/knowledge/agents/instructions-describe-work-not-tool-ids.bad.al create mode 100644 community/knowledge/agents/instructions-describe-work-not-tool-ids.good.al create mode 100644 community/knowledge/agents/instructions-describe-work-not-tool-ids.md create mode 100644 community/knowledge/agents/reapply-resource-instructions-on-upgrade.bad.al create mode 100644 community/knowledge/agents/reapply-resource-instructions-on-upgrade.good.al create mode 100644 community/knowledge/agents/reapply-resource-instructions-on-upgrade.md create mode 100644 community/knowledge/agents/register-copilot-capability-for-the-agent.bad.al create mode 100644 community/knowledge/agents/register-copilot-capability-for-the-agent.good.al create mode 100644 community/knowledge/agents/register-copilot-capability-for-the-agent.md create mode 100644 community/knowledge/agents/set-instructions-as-secrettext.bad.al create mode 100644 community/knowledge/agents/set-instructions-as-secrettext.good.al create mode 100644 community/knowledge/agents/set-instructions-as-secrettext.md create mode 100644 community/knowledge/agents/show-can-create-agent-does-not-block-code-create.bad.al create mode 100644 community/knowledge/agents/show-can-create-agent-does-not-block-code-create.good.al create mode 100644 community/knowledge/agents/show-can-create-agent-does-not-block-code-create.md create mode 100644 community/knowledge/agents/skip-incoming-review-only-for-trusted-input.bad.al create mode 100644 community/knowledge/agents/skip-incoming-review-only-for-trusted-input.good.al create mode 100644 community/knowledge/agents/skip-incoming-review-only-for-trusted-input.md create mode 100644 community/knowledge/agents/use-documented-instruction-keywords.bad.al create mode 100644 community/knowledge/agents/use-documented-instruction-keywords.good.al create mode 100644 community/knowledge/agents/use-documented-instruction-keywords.md create mode 100644 community/knowledge/agents/wire-all-three-agent-interfaces.bad.al create mode 100644 community/knowledge/agents/wire-all-three-agent-interfaces.good.al create mode 100644 community/knowledge/agents/wire-all-three-agent-interfaces.md create mode 100644 community/skills/review/al-agents-review.md diff --git a/README.md b/README.md index b939c6f..8946d9c 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Code examples belong in separate files, not in the knowledge file itself. Knowle ## Scope -The current curated corpus is focused on **technical AL code review**: AppSource and compatibility, data modeling, error handling, events, interfaces, performance, privacy, Query objects, security, style, telemetry, testing, UI, upgrade, and web services. These are the domains backed by knowledge files and registered review leaves today. +The current curated corpus is focused on **technical AL code review**: Agents, AppSource and compatibility, data modeling, error handling, events, interfaces, performance, privacy, Query objects, security, style, telemetry, testing, UI, upgrade, and web services. These are the domains backed by knowledge files and registered review leaves today. Business Central functional domains (Finance, Supply Chain Management, Manufacturing, Jobs, Warehousing, Service), PowerShell, pipelines, and Power Platform remain valid future repository scope, but they are **not current coverage claims** until corresponding knowledge and action skills exist. Consumers should derive supported review scope from the live knowledge index and dispatched skills, not from roadmap breadth. diff --git a/community/knowledge/agents/agent-permissions-intersect-with-assigner.bad.al b/community/knowledge/agents/agent-permissions-intersect-with-assigner.bad.al new file mode 100644 index 0000000..eab5ef7 --- /dev/null +++ b/community/knowledge/agents/agent-permissions-intersect-with-assigner.bad.al @@ -0,0 +1,11 @@ +permissionset 50100 "SALES REVIEW AGENT" +{ + Assignable = true; + Permissions = + tabledata "Sales Header" = RIM, + tabledata Customer = R, + tabledata User = RIMD, + tabledata "Access Control" = RIMD, + page "Sales Order" = X, + page "User Card" = X; +} diff --git a/community/knowledge/agents/agent-permissions-intersect-with-assigner.good.al b/community/knowledge/agents/agent-permissions-intersect-with-assigner.good.al new file mode 100644 index 0000000..836c6a5 --- /dev/null +++ b/community/knowledge/agents/agent-permissions-intersect-with-assigner.good.al @@ -0,0 +1,8 @@ +permissionset 50100 "SALES REVIEW AGENT" +{ + Assignable = true; + Permissions = + tabledata "Sales Header" = RIM, + tabledata Customer = R, + page "Sales Order" = X; +} diff --git a/community/knowledge/agents/agent-permissions-intersect-with-assigner.md b/community/knowledge/agents/agent-permissions-intersect-with-assigner.md new file mode 100644 index 0000000..2259f66 --- /dev/null +++ b/community/knowledge/agents/agent-permissions-intersect-with-assigner.md @@ -0,0 +1,30 @@ +--- +bc-version: [27..] +domain: agents +keywords: [permissions, assigner, intersection, user-card, least-privilege] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Agent permissions intersect the assigner's; agents cannot configure users + +## Description + +An agent is a user, but it cannot configure users or other agents, and it cannot open sensitive pages such as user cards or permission-set assignment. Effective rights are the intersection of the assigning user's permissions and the agent's permission sets. Granting the agent a wide set does not bypass the assigner's limits, and a wide assigner still cannot give the agent user-admin powers the platform forbids. + +## Best Practice + +Document that intersection. Give the agent only the table and page rights its tasks need. Do not add user-setup or permission-assignment pages to the agent profile or permission sets; those operations will fail by design. + +See sample: `agent-permissions-intersect-with-assigner.good.al`. + +## Anti Pattern + +Permission sets or profiles that include User card, Permission Set Assignment, or agent-admin pages, or comments that the agent runs as SUPER regardless of who assigned it. Detection signal: default access controls or profile including user-administration objects. + +See sample: `agent-permissions-intersect-with-assigner.bad.al`. + +## See also + +`get-default-access-controls-least-privilege.md` covers the permission sets assigned when an agent instance is created. diff --git a/community/knowledge/agents/agent-profile-narrows-visible-ui.bad.al b/community/knowledge/agents/agent-profile-narrows-visible-ui.bad.al new file mode 100644 index 0000000..58b1cba --- /dev/null +++ b/community/knowledge/agents/agent-profile-narrows-visible-ui.bad.al @@ -0,0 +1,8 @@ +codeunit 50100 "Sales Review Agent Factory" +{ + procedure GetDefaultProfile(var TempAllProfile: Record "All Profile" temporary) + begin + TempAllProfile."Profile ID" := 'BUSINESS MANAGER'; + TempAllProfile.Insert(); + end; +} diff --git a/community/knowledge/agents/agent-profile-narrows-visible-ui.good.al b/community/knowledge/agents/agent-profile-narrows-visible-ui.good.al new file mode 100644 index 0000000..5ffe61a --- /dev/null +++ b/community/knowledge/agents/agent-profile-narrows-visible-ui.good.al @@ -0,0 +1,26 @@ +profile "SALES REVIEW AGENT" +{ + Caption = 'Sales Review Agent'; + Description = 'Restricted UI for the Sales Review Agent.'; + RoleCenter = "Order Processor Role Center"; + Customizations = "Sales Review Agent Sales Ord."; +} + +pagecustomization "Sales Review Agent Sales Ord." customizes "Sales Order" +{ + layout + { + modify("Payment Terms Code") + { + Visible = false; + } + } + + actions + { + modify(Post) + { + Visible = false; + } + } +} diff --git a/community/knowledge/agents/agent-profile-narrows-visible-ui.md b/community/knowledge/agents/agent-profile-narrows-visible-ui.md new file mode 100644 index 0000000..30d286a --- /dev/null +++ b/community/knowledge/agents/agent-profile-narrows-visible-ui.md @@ -0,0 +1,30 @@ +--- +bc-version: [27..] +domain: agents +keywords: [profile, page-customization, hidden-actions, tooltip, role-center] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Give the agent a dedicated profile that hides unrelated UI + +## Description + +The agent only sees what its profile shows. Extra actions, views, and Role Center tiles become extra tools and extra tokens. Accuracy and cost both get worse as the UI widens. A human Order Processor profile is usually far too broad. Tooltips on the remaining actions are part of the tool description. + +## Best Practice + +Ship an agent-specific profile and page customizations: hide unrelated actions, keep descriptive tooltips, add Role Center links to the few pages the agent should open. Prefer fewer navigation hops. + +See sample: `agent-profile-narrows-visible-ui.good.al`. + +## Anti Pattern + +Assigning `BUSINESS MANAGER` or `ORDER PROCESSOR` as `GetDefaultProfile` so the agent can do anything. Detection signal: default profile equal to a full-user role with no agent page customizations. + +See sample: `agent-profile-narrows-visible-ui.bad.al`. + +## See also + +`get-default-profile-lives-in-the-app.md` covers packaging and assigning the profile that this rule narrows. diff --git a/community/knowledge/agents/agent-setup-page-is-configuration-dialog.bad.al b/community/knowledge/agents/agent-setup-page-is-configuration-dialog.bad.al new file mode 100644 index 0000000..c23bec1 --- /dev/null +++ b/community/knowledge/agents/agent-setup-page-is-configuration-dialog.bad.al @@ -0,0 +1,19 @@ +page 50100 "Sales Review Agent Setup" +{ + PageType = Card; + Caption = 'Set up Sales Review Agent'; + SourceTable = "Sales Review Agent Setup"; + + layout + { + area(Content) + { + field(ReviewThreshold; Rec."Review Threshold") + { + ApplicationArea = All; + Caption = 'Review Threshold'; + ToolTip = 'Specifies the threshold used when the agent requests a review.'; + } + } + } +} diff --git a/community/knowledge/agents/agent-setup-page-is-configuration-dialog.good.al b/community/knowledge/agents/agent-setup-page-is-configuration-dialog.good.al new file mode 100644 index 0000000..6c4eaf2 --- /dev/null +++ b/community/knowledge/agents/agent-setup-page-is-configuration-dialog.good.al @@ -0,0 +1,30 @@ +page 50100 "Sales Review Agent Setup" +{ + PageType = ConfigurationDialog; + Caption = 'Set up Sales Review Agent'; + SourceTable = "Sales Review Agent Setup"; + SourceTableTemporary = true; + Extensible = false; + + layout + { + area(Content) + { + part(AgentSetupPart; "Agent Setup Part") + { + ApplicationArea = All; + UpdatePropagation = Both; + } + group(AdditionalConfiguration) + { + Caption = 'Additional Configuration'; + field(ReviewThreshold; Rec."Review Threshold") + { + ApplicationArea = All; + Caption = 'Review Threshold'; + ToolTip = 'Specifies the threshold used when the agent requests a review.'; + } + } + } + } +} diff --git a/community/knowledge/agents/agent-setup-page-is-configuration-dialog.md b/community/knowledge/agents/agent-setup-page-is-configuration-dialog.md new file mode 100644 index 0000000..d844d33 --- /dev/null +++ b/community/knowledge/agents/agent-setup-page-is-configuration-dialog.md @@ -0,0 +1,26 @@ +--- +bc-version: [27..] +domain: agents +keywords: [configurationdialog, agent-setup-part, setup-page, pagetype, system-actions] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Agent setup pages use ConfigurationDialog and the Agent Setup Part + +## Description + +Instance setup is not a Card or StandardDialog. The toolkit expects `PageType = ConfigurationDialog` so OK and Cancel are system actions, plus the built-in `Agent Setup Part` for name, display name, state, and access. A Card with custom fields only drops those shared controls and the AI-use notices the part carries. + +## Best Practice + +Declare `PageType = ConfigurationDialog`, host `part(...; "Agent Setup Part")`, and put agent-specific fields in another group. Keep system OK/Cancel. Use a temporary source record and defer persistence until Update, as described in `agent-setup-source-table-is-temporary.md`. Following Microsoft's agent setup samples, set `Extensible = false`. + +See sample: `agent-setup-page-is-configuration-dialog.good.al`. + +## Anti Pattern + +A Card or StandardDialog setup page with no `Agent Setup Part`. Detection signal: setup page ID from `IAgentFactory` / `IAgentMetadata` whose page is not `ConfigurationDialog` or has no `Agent Setup Part`. + +See sample: `agent-setup-page-is-configuration-dialog.bad.al`. diff --git a/community/knowledge/agents/agent-setup-source-table-is-temporary.bad.al b/community/knowledge/agents/agent-setup-source-table-is-temporary.bad.al new file mode 100644 index 0000000..96ef570 --- /dev/null +++ b/community/knowledge/agents/agent-setup-source-table-is-temporary.bad.al @@ -0,0 +1,29 @@ +page 50100 "Sales Review Agent Setup" +{ + PageType = ConfigurationDialog; + SourceTable = "Sales Review Agent Setup"; + + layout + { + area(Content) + { + field(ReviewThreshold; Rec."Review Threshold") + { + ApplicationArea = All; + Caption = 'Review Threshold'; + ToolTip = 'Specifies the threshold used when the agent requests a review.'; + + trigger OnValidate() + begin + Rec.Modify(true); + end; + } + } + } + + trigger OnOpenPage() + begin + if Rec.IsEmpty() then + Rec.Insert(true); + end; +} diff --git a/community/knowledge/agents/agent-setup-source-table-is-temporary.good.al b/community/knowledge/agents/agent-setup-source-table-is-temporary.good.al new file mode 100644 index 0000000..cbeabca --- /dev/null +++ b/community/knowledge/agents/agent-setup-source-table-is-temporary.good.al @@ -0,0 +1,68 @@ +page 50100 "Sales Review Agent Setup" +{ + PageType = ConfigurationDialog; + SourceTable = "Sales Review Agent Setup"; + SourceTableTemporary = true; + Extensible = false; + + layout + { + area(Content) + { + part(AgentSetupPart; "Agent Setup Part") + { + ApplicationArea = All; + UpdatePropagation = Both; + } + group(AdditionalConfiguration) + { + Caption = 'Additional Configuration'; + field(ReviewThreshold; Rec."Review Threshold") + { + ApplicationArea = All; + Caption = 'Review Threshold'; + ToolTip = 'Specifies the threshold used when the agent requests a review.'; + } + } + } + } + + trigger OnOpenPage() + var + SalesReviewAgentSetup: Record "Sales Review Agent Setup"; + begin + if IsNullGuid(Rec."User Security ID") then + exit; + if SalesReviewAgentSetup.Get(Rec."User Security ID") then + Rec := SalesReviewAgentSetup; + end; + + trigger OnQueryClosePage(CloseAction: Action): Boolean + var + AgentSetup: Codeunit "Agent Setup"; + AgentSetupBuffer: Record "Agent Setup Buffer"; + begin + if CloseAction = CloseAction::Cancel then + exit(true); + CurrPage.AgentSetupPart.Page.GetAgentSetupBuffer(AgentSetupBuffer); + if AgentSetup.GetChangesMade(AgentSetupBuffer) then + Rec."User Security ID" := AgentSetup.SaveChanges(AgentSetupBuffer); + if IsNullGuid(Rec."User Security ID") then + exit(true); + SaveCustomProperties(); + exit(true); + end; + + local procedure SaveCustomProperties() + var + SalesReviewAgentSetup: Record "Sales Review Agent Setup"; + begin + if not SalesReviewAgentSetup.Get(Rec."User Security ID") then begin + SalesReviewAgentSetup.Init(); + SalesReviewAgentSetup."User Security ID" := Rec."User Security ID"; + SalesReviewAgentSetup.Insert(true); + end; + SalesReviewAgentSetup."Review Threshold" := Rec."Review Threshold"; + SalesReviewAgentSetup.Modify(true); + end; +} diff --git a/community/knowledge/agents/agent-setup-source-table-is-temporary.md b/community/knowledge/agents/agent-setup-source-table-is-temporary.md new file mode 100644 index 0000000..8f31aa0 --- /dev/null +++ b/community/knowledge/agents/agent-setup-source-table-is-temporary.md @@ -0,0 +1,30 @@ +--- +bc-version: [27..] +domain: agents +keywords: [sourcetabletemporary, configurationdialog, savechanges, cancel, draft] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep the agent setup page source temporary until Update + +## Description + +ConfigurationDialog setup is a draft: the user can Cancel without writing. That only works if `SourceTableTemporary = true` and custom fields stay in memory until Update. Writing the real table in OnValidate or OnOpenPage commits a partial agent when the dialog errors or is cancelled. + +## Best Practice + +Mark the page `SourceTableTemporary = true`. Copy into the temp record on open. Persist the Agent Setup buffer and custom fields only from the close path when the action is not Cancel, using `Agent Setup.GetChangesMade` / `SaveChanges`. + +See sample: `agent-setup-source-table-is-temporary.good.al`. + +## Anti Pattern + +A non-temporary source table, or `Insert`/`Modify` on the persisted setup row from field OnValidate. Detection signal: agent `ConfigurationDialog` without `SourceTableTemporary = true`, or database writes before Update. + +See sample: `agent-setup-source-table-is-temporary.bad.al`. + +## See also + +`agent-setup-page-is-configuration-dialog.md` defines the setup page shape that uses this draft lifecycle. diff --git a/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.bad.al b/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.bad.al new file mode 100644 index 0000000..cff2738 --- /dev/null +++ b/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.bad.al @@ -0,0 +1,24 @@ +table 50100 "Sales Review Agent Setup" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Primary Key"; Code[10]) + { + Caption = 'Primary Key'; + } + field(10; "Review Threshold"; Decimal) + { + Caption = 'Review Threshold'; + } + } + + keys + { + key(PK; "Primary Key") + { + Clustered = true; + } + } +} diff --git a/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.good.al b/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.good.al new file mode 100644 index 0000000..f5b1f9b --- /dev/null +++ b/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.good.al @@ -0,0 +1,25 @@ +table 50100 "Sales Review Agent Setup" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "User Security ID"; Guid) + { + Caption = 'User Security ID'; + DataClassification = EndUserPseudonymousIdentifiers; + } + field(10; "Review Threshold"; Decimal) + { + Caption = 'Review Threshold'; + } + } + + keys + { + key(PK; "User Security ID") + { + Clustered = true; + } + } +} diff --git a/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.md b/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.md new file mode 100644 index 0000000..c59a9f1 --- /dev/null +++ b/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.md @@ -0,0 +1,26 @@ +--- +bc-version: [27..] +domain: agents +keywords: [user-security-id, setup-table, primary-key, agent-instance, guid] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Agent setup tables are keyed by User Security ID + +## Description + +Each agent instance is a user. Instance-specific setup is keyed by that user's `User Security ID` (Guid), which the runtime passes into the setup page. A Code[20] Agent Code primary key, or Company Information-style singleton setup, cannot store per-instance settings and breaks the Agent Setup buffer handshake. + +## Best Practice + +Give the setup table a Guid field `User Security ID` as the clustered primary key. Other settings are attributes of that key. When the page opens, `Get` or insert by the Guid the Agent Setup part already holds. + +See sample: `agent-setup-table-keyed-by-user-security-id.good.al`. + +## Anti Pattern + +A setup table keyed by Code, Integer, or with no Guid user key, then mapping one row to every instance. Detection signal: source table of the agent setup page whose primary key is not `User Security ID`. + +See sample: `agent-setup-table-keyed-by-user-security-id.bad.al`. diff --git a/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.bad.al b/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.bad.al new file mode 100644 index 0000000..d75db08 --- /dev/null +++ b/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.bad.al @@ -0,0 +1,8 @@ +codeunit 50100 "Sales Review Agent Task" +{ + procedure AnalyzeAgentTaskMessage(AgentTaskMessage: Record "Agent Task Message"; var Annotations: Record "Agent Annotation") + begin + // No validation. Combined with SetRequiresReview(false) this auto-runs + // untrusted input. Warnings are the only way to force a review later. + end; +} diff --git a/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.good.al b/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.good.al new file mode 100644 index 0000000..c3bbc18 --- /dev/null +++ b/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.good.al @@ -0,0 +1,41 @@ +codeunit 50100 "Sales Review Agent Task" +{ + procedure AnalyzeAgentTaskMessage(AgentTaskMessage: Record "Agent Task Message"; var Annotations: Record "Agent Annotation") + var + AgentMessage: Codeunit "Agent Message"; + EmptyMessageMsg: Label 'Message is empty.'; + EmptyMessageDetailsTxt: Label 'Provide a sales order task before running the agent.'; + NotRelevantMsg: Label 'Message is not a sales order task.'; + NotRelevantDetailsTxt: Label 'Provide a message related to sales order review.'; + MessageText: Text; + begin + if AgentTaskMessage.Type = AgentTaskMessage.Type::Output then begin + AgentMessage.UpdateText(AgentTaskMessage, AgentMessage.GetText(AgentTaskMessage) + #13#10 + #13#10 + 'Written with the help of AI'); + exit; + end; + + MessageText := AgentMessage.GetText(AgentTaskMessage); + if MessageText = '' then begin + Clear(Annotations); + Annotations.Code := 'MESSAGE001'; + Annotations.Severity := Annotations.Severity::Error; + Annotations.Message := EmptyMessageMsg; + Annotations.Details := EmptyMessageDetailsTxt; + Annotations.Insert(); + exit; + end; + if not IsRelevant(MessageText) then begin + Clear(Annotations); + Annotations.Code := 'RELEVANCE001'; + Annotations.Severity := Annotations.Severity::Warning; + Annotations.Message := NotRelevantMsg; + Annotations.Details := NotRelevantDetailsTxt; + Annotations.Insert(); + end; + end; + + local procedure IsRelevant(MessageText: Text): Boolean + begin + exit(StrPos(LowerCase(MessageText), 'sales order') > 0); + end; +} diff --git a/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.md b/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.md new file mode 100644 index 0000000..f8c6eb5 --- /dev/null +++ b/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.md @@ -0,0 +1,26 @@ +--- +bc-version: [27..] +domain: agents +keywords: [analyzeagenttaskmessage, agent-annotation, error, warning, setrequiresreview] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AnalyzeAgentTaskMessage: Error stops the task; Warning still requires review + +## Description + +`IAgentTaskExecution.AnalyzeAgentTaskMessage` runs on inbound and outbound messages. An Error annotation stops processing. A Warning annotation requests user intervention. If analysis returns Warning, the platform still requires approval even when the incoming message used `SetRequiresReview(false)`. Output text can be rewritten here (signature, redaction). + +## Best Practice + +Validate inbound payloads in analysis: Error when the task must not run; Warning when a human must confirm. For outbound messages, adjust text in this method rather than in a later subscriber. Do not rely on skip-review to bypass warnings. + +See sample: `analyze-message-error-stops-warning-forces-review.good.al`. + +## Anti Pattern + +Ignoring analysis entirely, or emitting Warning while documenting that `SetRequiresReview(false)` means unattended run. Detection signal: empty `AnalyzeAgentTaskMessage` plus skip-review on external input. + +See sample: `analyze-message-error-stops-warning-forces-review.bad.al`. diff --git a/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.bad.al b/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.bad.al new file mode 100644 index 0000000..998066c --- /dev/null +++ b/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.bad.al @@ -0,0 +1,9 @@ +codeunit 50101 "Sales Review Agent Events" +{ + [EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterInsertEvent, '', false, false)] + local procedure OnAfterInsertSalesHeader(var Rec: Record "Sales Header") + begin + // Runs for every user session, not only the agent. + Message('Keep going, agent.'); + end; +} diff --git a/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.good.al b/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.good.al new file mode 100644 index 0000000..e4894fd --- /dev/null +++ b/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.good.al @@ -0,0 +1,34 @@ +codeunit 50101 "Sales Review Agent Subscribers" +{ + Access = Internal; + EventSubscriberInstance = Manual; + SingleInstance = true; + + [EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterInsertEvent, '', false, false)] + local procedure OnAfterInsertSalesHeader(var Rec: Record "Sales Header") + begin + Message('Keep going, agent.'); + end; +} + +codeunit 50102 "Agent Session Events" +{ + Access = Internal; + SingleInstance = true; + InherentEntitlements = X; + InherentPermissions = X; + + var + GlobalAgentSubscribers: Codeunit "Sales Review Agent Subscribers"; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"System Initialization", OnAfterInitialization, '', false, false)] + local procedure RegisterSubscribersOnAfterInitialization() + var + AgentSession: Codeunit "Agent Session"; + AgentMetadataProvider: Enum "Agent Metadata Provider"; + begin + if not AgentSession.IsAgentSession(AgentMetadataProvider) then + exit; + if BindSubscription(GlobalAgentSubscribers) then; + end; +} diff --git a/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.md b/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.md new file mode 100644 index 0000000..3d18818 --- /dev/null +++ b/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.md @@ -0,0 +1,26 @@ +--- +bc-version: [27..] +domain: agents +keywords: [agent-session, isagentsession, bindsubscription, system-initialization, singleinstance] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Bind extra agent subscribers only inside an agent session + +## Description + +Page-filter tweaks, extra validation, and prompt dialogs for the agent should not run for every user. `Agent Session.IsAgentSession` distinguishes agent UI sessions. Binding those subscribers on `System Initialization` only when the session is an agent session avoids global subscriber cost. Models register `SingleInstance` table subscribers unconditionally. + +## Best Practice + +On `OnAfterInitialization`, exit unless `Agent Session.IsAgentSession`. Then `BindSubscription` a single-instance codeunit that holds the current task id. Keep those subscribers internal. + +See sample: `bind-agent-subscribers-only-in-agent-session.good.al`. + +## Anti Pattern + +Event subscribers on `Sales Header` OnAfterInsert that always `Message` the agent, with no `IsAgentSession` guard. Detection signal: agent-only behaviour in a static subscriber that is not bind-gated. + +See sample: `bind-agent-subscribers-only-in-agent-session.bad.al`. diff --git a/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.bad.al b/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.bad.al new file mode 100644 index 0000000..93a7afe --- /dev/null +++ b/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.bad.al @@ -0,0 +1,10 @@ +codeunit 50110 "Other App Agent Hook" +{ + procedure RenameForeignAgent(AgentUserSecurityId: Guid) + var + Agent: Codeunit Agent; + begin + // Fails at runtime when the instance was defined in another app. + Agent.SetDisplayName(AgentUserSecurityId, 'Updated Name'); + end; +} diff --git a/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.good.al b/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.good.al new file mode 100644 index 0000000..51181e8 --- /dev/null +++ b/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.good.al @@ -0,0 +1,21 @@ +codeunit 50110 "Sales Review Agent API" +{ + Access = Public; + + procedure SetDisplayName(AgentUserSecurityId: Guid; NewDisplayName: Text[80]) + var + Agent: Codeunit Agent; + begin + Agent.SetDisplayName(AgentUserSecurityId, NewDisplayName); + end; + + procedure SetActiveState(AgentUserSecurityId: Guid; ActivateAgent: Boolean) + var + Agent: Codeunit Agent; + begin + if ActivateAgent then + Agent.Activate(AgentUserSecurityId) + else + Agent.Deactivate(AgentUserSecurityId); + end; +} diff --git a/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.md b/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.md new file mode 100644 index 0000000..41c1c2f --- /dev/null +++ b/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.md @@ -0,0 +1,26 @@ +--- +bc-version: [27..] +domain: agents +keywords: [cross-app, public-api, agent-create, isolation, access-public] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Other apps cannot call the toolkit APIs on your agent; publish your own API + +## Description + +For isolation, `Agent`, `Agent Task Builder`, and related toolkit codeunits error when the target instance belongs to another app. There is no supported way to pass another extension's metadata provider into `SetInstructions` or `Create`. Partners who need to enqueue work must call a public API you own. + +## Best Practice + +Expose a public codeunit in the agent app (`Access = Public`) whose procedures take `User Security ID` and forward to `Agent` / `Agent Task Builder`. Document that surface as the integration contract. Keep toolkit calls inside that app. + +See sample: `cross-app-agent-calls-need-your-public-api.good.al`. + +## Anti Pattern + +From app B, calling `Agent.SetDisplayName` or `Agent.Create` with app A's metadata provider. Detection signal: toolkit agent APIs used with an `Agent Metadata Provider` value not declared in the same app. + +See sample: `cross-app-agent-calls-need-your-public-api.bad.al`. diff --git a/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.bad.al b/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.bad.al new file mode 100644 index 0000000..26cbb38 --- /dev/null +++ b/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.bad.al @@ -0,0 +1,19 @@ +codeunit 50100 "Sales Review Agent Install" +{ + Subtype = Install; + + trigger OnInstallAppPerCompany() + var + Agent: Codeunit Agent; + TempAgentAccessControl: Record "Agent Access Control" temporary; + AgentUserSecurityId: Guid; + begin + // Create requires an interactive session. Install is not one. + AgentUserSecurityId := Agent.Create( + Enum::"Agent Metadata Provider"::"Sales Review Agent", + 'SALESREVIEW', + 'Sales Review Agent', + TempAgentAccessControl); + Agent.Activate(AgentUserSecurityId); + end; +} diff --git a/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.good.al b/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.good.al new file mode 100644 index 0000000..a1831f4 --- /dev/null +++ b/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.good.al @@ -0,0 +1,44 @@ +page 50100 "Sales Review Agent Setup" +{ + PageType = ConfigurationDialog; + ApplicationArea = All; + SourceTable = "Sales Review Agent Setup"; + SourceTableTemporary = true; + Extensible = false; + + layout + { + area(Content) + { + part(AgentSetupPart; "Agent Setup Part") + { + ApplicationArea = All; + UpdatePropagation = Both; + } + } + } + + trigger OnQueryClosePage(CloseAction: Action): Boolean + var + Agent: Codeunit Agent; + AgentSetup: Codeunit "Agent Setup"; + TempAgentSetupBuffer: Record "Agent Setup Buffer" temporary; + AgentUserSecurityId: Guid; + begin + if CloseAction = CloseAction::Cancel then + exit(true); + + CurrPage.AgentSetupPart.Page.GetAgentSetupBuffer(TempAgentSetupBuffer); + AgentUserSecurityId := AgentSetup.SaveChanges(TempAgentSetupBuffer); + Agent.SetInstructions(AgentUserSecurityId, GetInstructions()); + Agent.Activate(AgentUserSecurityId); + exit(true); + end; + + local procedure GetInstructions() Instructions: SecretText + var + InstructionsNameTxt: Label 'Instructions.txt', Locked = true; + begin + Instructions := NavApp.GetResourceAsText(InstructionsNameTxt); + end; +} diff --git a/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.md b/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.md new file mode 100644 index 0000000..41d0573 --- /dev/null +++ b/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.md @@ -0,0 +1,26 @@ +--- +bc-version: [27..] +domain: agents +keywords: [agent-create, install, upgrade, job-queue, interactive-session, background] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not create agent instances from install, upgrade, or background sessions + +## Description + +`Agent.Create` requires an interactive user session. The platform blocks creation from install codeunits, upgrade codeunits, and background sessions (job queue, scheduled tasks). Packaging an agent in an app does not mean spinning up instances at install. Models still call `Create` from `OnInstallAppPerCompany` to activate the agent. + +## Best Practice + +Create instances from a setup page, a wizard, or another UI-driven path after the user is in a client session. Apply instructions and `Activate` there. For existing companies after an upgrade, document that an admin must open setup; do not create from the upgrade codeunit. + +See sample: `do-not-create-agents-in-install-upgrade-or-background.good.al`. + +## Anti Pattern + +`Agent.Create` inside `OnInstallAppPerCompany`, `OnUpgradePerCompany`, or a job-queue codeunit. The call fails at runtime even if it compiles. Detection signal: `Agent.Create` in `Subtype = Install`, `Subtype = Upgrade`, or a non-UI session. + +See sample: `do-not-create-agents-in-install-upgrade-or-background.bad.al`. diff --git a/community/knowledge/agents/get-default-access-controls-least-privilege.bad.al b/community/knowledge/agents/get-default-access-controls-least-privilege.bad.al new file mode 100644 index 0000000..618f964 --- /dev/null +++ b/community/knowledge/agents/get-default-access-controls-least-privilege.bad.al @@ -0,0 +1,14 @@ +codeunit 50100 "Sales Review Agent Factory" +{ + procedure GetDefaultAccessControls(var TempAccessControlBuffer: Record "Access Control Buffer" temporary) + var + BaseApplicationAppIdTok: Label '437dbf0e-84ff-417a-965d-ed2bb9650972', Locked = true; + begin + Clear(TempAccessControlBuffer); + TempAccessControlBuffer."Company Name" := CopyStr(CompanyName(), 1, MaxStrLen(TempAccessControlBuffer."Company Name")); + TempAccessControlBuffer.Scope := TempAccessControlBuffer.Scope::System; + TempAccessControlBuffer."App ID" := BaseApplicationAppIdTok; + TempAccessControlBuffer."Role ID" := 'D365 BUS FULL ACCESS'; + TempAccessControlBuffer.Insert(); + end; +} diff --git a/community/knowledge/agents/get-default-access-controls-least-privilege.good.al b/community/knowledge/agents/get-default-access-controls-least-privilege.good.al new file mode 100644 index 0000000..2c855f7 --- /dev/null +++ b/community/knowledge/agents/get-default-access-controls-least-privilege.good.al @@ -0,0 +1,25 @@ +permissionset 50100 "SALES REVIEW AGENT" +{ + Assignable = true; + Caption = 'Sales Review Agent'; + Permissions = + tabledata "Sales Header" = R, + tabledata "Sales Line" = R; +} + +codeunit 50100 "Sales Review Agent Factory" +{ + procedure GetDefaultAccessControls(var TempAccessControlBuffer: Record "Access Control Buffer" temporary) + var + CurrentModuleInfo: ModuleInfo; + RoleIdTok: Label 'SALES REVIEW AGENT', Locked = true; + begin + NavApp.GetCurrentModuleInfo(CurrentModuleInfo); + Clear(TempAccessControlBuffer); + TempAccessControlBuffer."Company Name" := CopyStr(CompanyName(), 1, MaxStrLen(TempAccessControlBuffer."Company Name")); + TempAccessControlBuffer.Scope := TempAccessControlBuffer.Scope::System; + TempAccessControlBuffer."App ID" := CurrentModuleInfo.Id; + TempAccessControlBuffer."Role ID" := RoleIdTok; + TempAccessControlBuffer.Insert(); + end; +} diff --git a/community/knowledge/agents/get-default-access-controls-least-privilege.md b/community/knowledge/agents/get-default-access-controls-least-privilege.md new file mode 100644 index 0000000..87349af --- /dev/null +++ b/community/knowledge/agents/get-default-access-controls-least-privilege.md @@ -0,0 +1,30 @@ +--- +bc-version: [27..] +domain: agents +keywords: [getdefaultaccesscontrols, access-control-buffer, permissionset, least-privilege, iagentfactory] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Default agent permission sets must exist in AL and stay least privilege + +## Description + +`IAgentFactory.GetDefaultAccessControls` fills a temporary `Access Control Buffer` used when an instance is created. Permission sets that exist only as user-created sets in a sandbox are missing in the next environment. Granting `D365 BUS FULL ACCESS` or SUPER gives the agent a user-sized blast radius. Effective rights are still the intersection with the assigning user's permissions. + +## Best Practice + +Insert only the permission sets the agent needs. For an AL `permissionset` object, use `Scope::System` and the ID of the app that defines it. Recreate permission sets that exist only as user-defined configuration in Business Central as AL objects first. Prefer a dedicated permission set over a full-user role. + +See sample: `get-default-access-controls-least-privilege.good.al`. + +## Anti Pattern + +Empty `GetDefaultAccessControls`, or inserting `SUPER` / `D365 BUS FULL ACCESS` because it made the demo work. Detection signal: Role ID on the default buffer that is a full-user role, or a set that is not in the app. + +See sample: `get-default-access-controls-least-privilege.bad.al`. + +## See also + +`agent-permissions-intersect-with-assigner.md` explains the platform limits that still apply after default access controls are assigned. diff --git a/community/knowledge/agents/get-default-profile-lives-in-the-app.bad.al b/community/knowledge/agents/get-default-profile-lives-in-the-app.bad.al new file mode 100644 index 0000000..ba713e4 --- /dev/null +++ b/community/knowledge/agents/get-default-profile-lives-in-the-app.bad.al @@ -0,0 +1,9 @@ +codeunit 50100 "Sales Review Agent Factory" +{ + procedure GetDefaultProfile(var TempAllProfile: Record "All Profile" temporary) + begin + // Profile exists only as a user personalization in the design sandbox. + TempAllProfile."Profile ID" := 'SALES REVIEW SANDBOX'; + TempAllProfile.Insert(); + end; +} diff --git a/community/knowledge/agents/get-default-profile-lives-in-the-app.good.al b/community/knowledge/agents/get-default-profile-lives-in-the-app.good.al new file mode 100644 index 0000000..455fba6 --- /dev/null +++ b/community/knowledge/agents/get-default-profile-lives-in-the-app.good.al @@ -0,0 +1,20 @@ +profile "SALES REVIEW AGENT" +{ + Caption = 'Sales Review Agent'; + Description = 'UI surface for the Sales Review Agent.'; + RoleCenter = "Order Processor Role Center"; + Customizations = "Sales Review Agent Sales Ord."; +} + +codeunit 50100 "Sales Review Agent Factory" +{ + procedure GetDefaultProfile(var TempAllProfile: Record "All Profile" temporary) + var + Agent: Codeunit Agent; + CurrentModuleInfo: ModuleInfo; + DefaultProfileTok: Label 'SALES REVIEW AGENT', Locked = true; + begin + NavApp.GetCurrentModuleInfo(CurrentModuleInfo); + Agent.PopulateDefaultProfile(DefaultProfileTok, CurrentModuleInfo.Id, TempAllProfile); + end; +} diff --git a/community/knowledge/agents/get-default-profile-lives-in-the-app.md b/community/knowledge/agents/get-default-profile-lives-in-the-app.md new file mode 100644 index 0000000..25103b5 --- /dev/null +++ b/community/knowledge/agents/get-default-profile-lives-in-the-app.md @@ -0,0 +1,30 @@ +--- +bc-version: [27..] +domain: agents +keywords: [getdefaultprofile, profile, page-customization, populatedefaultprofile, role-center] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# The default agent profile must be an AL profile in the app + +## Description + +`IAgentFactory.GetDefaultProfile` assigns the Role Center and page customizations the agent UI-navigates. A profile built only in the client, or page personalization that was never exported, is absent after deploy. `Agent.PopulateDefaultProfile` still needs a profile ID that exists in the current module. + +## Best Practice + +Ship a `profile` object (and page customizations) in the app. In `GetDefaultProfile`, call `Agent.PopulateDefaultProfile` with that profile ID and `NavApp.GetCurrentModuleInfo`. Include UI-exported customizations as AL. + +See sample: `get-default-profile-lives-in-the-app.good.al`. + +## Anti Pattern + +Setting `TempAllProfile."Profile ID"` to a client-only profile, or skipping `GetDefaultProfile`. Detection signal: factory default profile ID with no matching `profile` object in the app. + +See sample: `get-default-profile-lives-in-the-app.bad.al`. + +## See also + +`agent-profile-narrows-visible-ui.md` explains which UI the app-owned profile should expose. diff --git a/community/knowledge/agents/instruction-structure-is-role-rules-steps.bad.al b/community/knowledge/agents/instruction-structure-is-role-rules-steps.bad.al new file mode 100644 index 0000000..27d1a08 --- /dev/null +++ b/community/knowledge/agents/instruction-structure-is-role-rules-steps.bad.al @@ -0,0 +1,9 @@ +codeunit 50100 "Sales Review Agent Instr." +{ + procedure GetInstructions() Instructions: SecretText + var + PromptLbl: Label 'Check customer credit for the given sales order. Document the result.', Locked = true; + begin + Instructions := PromptLbl; + end; +} diff --git a/community/knowledge/agents/instruction-structure-is-role-rules-steps.good.al b/community/knowledge/agents/instruction-structure-is-role-rules-steps.good.al new file mode 100644 index 0000000..5876151 --- /dev/null +++ b/community/knowledge/agents/instruction-structure-is-role-rules-steps.good.al @@ -0,0 +1,17 @@ +codeunit 50100 "Sales Review Agent Instr." +{ + procedure GetInstructions() Instructions: SecretText + var + Builder: TextBuilder; + begin + Builder.AppendLine('# Responsibilities'); + Builder.AppendLine('You validate sales orders against customer credit and hold status.'); + Builder.AppendLine('# Guidelines'); + Builder.AppendLine('Always request a review before posting or sending external mail.'); + Builder.AppendLine('# Instructions'); + Builder.AppendLine('1. Open the sales order named in the task.'); + Builder.AppendLine('2. Check credit limit and overdue balance.'); + Builder.AppendLine('3. Document the result on the order and request a review.'); + Instructions := Builder.ToText(); + end; +} diff --git a/community/knowledge/agents/instruction-structure-is-role-rules-steps.md b/community/knowledge/agents/instruction-structure-is-role-rules-steps.md new file mode 100644 index 0000000..1b56625 --- /dev/null +++ b/community/knowledge/agents/instruction-structure-is-role-rules-steps.md @@ -0,0 +1,30 @@ +--- +bc-version: [27..] +domain: agents +keywords: [instructions, responsibilities, guidelines, steps, setinstructions] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Instruction documents use responsibilities, guidelines, then ordered steps + +## Description + +The runtime treats instructions as the agent's standing prompt. A one-line goal produces inconsistent navigation. Microsoft's instruction framework is three layers: responsibilities (what the agent owns), guidelines (rules for every task), and instructions (ordered steps per task, with substeps). That structure is BC-specific, not generic prompt flavour. + +## Best Practice + +Store a document that states responsibilities, then non-negotiable guidelines (when to request a review, when not to post), then numbered steps for each task. Keep that text in the resource you pass to `SetInstructions`. + +See sample: `instruction-structure-is-role-rules-steps.good.al`. + +## Anti Pattern + +A single sentence such as Check customer credit for the sales order. Detection signal: instruction resource or `SetInstructions` payload with no responsibilities / guidelines / steps sections. + +See sample: `instruction-structure-is-role-rules-steps.bad.al`. + +## See also + +`instructions-describe-work-not-tool-ids.md` and `use-documented-instruction-keywords.md` define how to write the steps inside this structure. diff --git a/community/knowledge/agents/instructions-describe-work-not-tool-ids.bad.al b/community/knowledge/agents/instructions-describe-work-not-tool-ids.bad.al new file mode 100644 index 0000000..4662547 --- /dev/null +++ b/community/knowledge/agents/instructions-describe-work-not-tool-ids.bad.al @@ -0,0 +1,7 @@ +codeunit 50100 "Sales Review Agent Instr." +{ + procedure GetInstructions() Instructions: SecretText + begin + Instructions := 'Open page 42. Invoke action Post_Promoted. Use tool SalesOrder.CreditCheck_v3.'; + end; +} diff --git a/community/knowledge/agents/instructions-describe-work-not-tool-ids.good.al b/community/knowledge/agents/instructions-describe-work-not-tool-ids.good.al new file mode 100644 index 0000000..75fd986 --- /dev/null +++ b/community/knowledge/agents/instructions-describe-work-not-tool-ids.good.al @@ -0,0 +1,12 @@ +codeunit 50100 "Sales Review Agent Instr." +{ + procedure GetInstructions() Instructions: SecretText + var + Builder: TextBuilder; + begin + Builder.AppendLine('Memorize the sales order number from the task.'); + Builder.AppendLine('Set the order on hold when credit fails, with a reason.'); + Builder.AppendLine('When credit passes, request a review before posting the order.'); + Instructions := Builder.ToText(); + end; +} diff --git a/community/knowledge/agents/instructions-describe-work-not-tool-ids.md b/community/knowledge/agents/instructions-describe-work-not-tool-ids.md new file mode 100644 index 0000000..a1a536a --- /dev/null +++ b/community/knowledge/agents/instructions-describe-work-not-tool-ids.md @@ -0,0 +1,30 @@ +--- +bc-version: [27..] +domain: agents +keywords: [instructions, tools, invoke-action, memorize, page-actions] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Instructions describe outcomes, not page action or tool names + +## Description + +Agent tools are the UI the profile exposes. Action names and tool ids change across pages and versions. Best-practice guidance is to say what to accomplish, not which tool to invoke. Page state is also not fully in history; values needed later must be memorized. Models paste Promoted action names into the prompt. + +## Best Practice + +Write steps as business outcomes (release the order, set the hold reason). Tell the agent to memorize identifiers it must reuse. Do not hard-code action captions or tool ids. + +See sample: `instructions-describe-work-not-tool-ids.good.al`. + +## Anti Pattern + +Instructions that say invoke SalesOrder.Post_Promoted or use tool page-42-action-3. Detection signal: instruction text containing Promoted action names or tool identifiers. + +See sample: `instructions-describe-work-not-tool-ids.bad.al`. + +## See also + +`instruction-structure-is-role-rules-steps.md` defines the containing document structure, and `use-documented-instruction-keywords.md` identifies runtime-recognized phrases. diff --git a/community/knowledge/agents/reapply-resource-instructions-on-upgrade.bad.al b/community/knowledge/agents/reapply-resource-instructions-on-upgrade.bad.al new file mode 100644 index 0000000..56ca228 --- /dev/null +++ b/community/knowledge/agents/reapply-resource-instructions-on-upgrade.bad.al @@ -0,0 +1,18 @@ +codeunit 50100 "Sales Review Agent Create" +{ + procedure CreateWithInstructions() + var + Agent: Codeunit Agent; + TempAgentAccessControl: Record "Agent Access Control" temporary; + AgentUserSecurityId: Guid; + InstructionsNameTxt: Label 'Instructions.txt', Locked = true; + begin + AgentUserSecurityId := Agent.Create( + Enum::"Agent Metadata Provider"::"Sales Review Agent", + 'SALESREVIEW', + 'Sales Review Agent', + TempAgentAccessControl); + // Only new instances get the resource. Upgrades never re-apply it. + Agent.SetInstructions(AgentUserSecurityId, NavApp.GetResourceAsText(InstructionsNameTxt)); + end; +} diff --git a/community/knowledge/agents/reapply-resource-instructions-on-upgrade.good.al b/community/knowledge/agents/reapply-resource-instructions-on-upgrade.good.al new file mode 100644 index 0000000..da255d0 --- /dev/null +++ b/community/knowledge/agents/reapply-resource-instructions-on-upgrade.good.al @@ -0,0 +1,33 @@ +codeunit 50100 "Sales Review Agent Upgrade" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + Agent: Codeunit Agent; + UpgradeTag: Codeunit "Upgrade Tag"; + Instructions: SecretText; + AgentUserSecurityIds: List of [Guid]; + AgentUserSecurityId: Guid; + TagTxt: Label 'SALESREVIEW-INSTR-2.0.0', Locked = true; + InstructionsNameTxt: Label 'Instructions.txt', Locked = true; + begin + if UpgradeTag.HasUpgradeTag(TagTxt) then + exit; + Instructions := NavApp.GetResourceAsText(InstructionsNameTxt); + AgentUserSecurityIds := GetExistingAgentUserIds(); + foreach AgentUserSecurityId in AgentUserSecurityIds do + Agent.SetInstructions(AgentUserSecurityId, Instructions); + UpgradeTag.SetUpgradeTag(TagTxt); + end; + + local procedure GetExistingAgentUserIds() AgentUserSecurityIds: List of [Guid] + var + SalesReviewAgentSetup: Record "Sales Review Agent Setup"; + begin + if SalesReviewAgentSetup.FindSet() then + repeat + AgentUserSecurityIds.Add(SalesReviewAgentSetup."User Security ID"); + until SalesReviewAgentSetup.Next() = 0; + end; +} diff --git a/community/knowledge/agents/reapply-resource-instructions-on-upgrade.md b/community/knowledge/agents/reapply-resource-instructions-on-upgrade.md new file mode 100644 index 0000000..5345d25 --- /dev/null +++ b/community/knowledge/agents/reapply-resource-instructions-on-upgrade.md @@ -0,0 +1,26 @@ +--- +bc-version: [27..] +domain: agents +keywords: [upgrade, setinstructions, navapp-getresourceastext, existing-instances, upgrade-tag] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Reapply resource instructions to existing agent instances on upgrade + +## Description + +Static instructions stored as an app resource are copied onto an instance only when you call `SetInstructions`. Shipping a new `Instructions.txt` in version 2.0 does not update agents created under 1.0. Models change the resource and assume running instances pick it up. + +## Best Practice + +In the upgrade codeunit, find existing instances of your metadata provider and call `SetInstructions` again with `NavApp.GetResourceAsText`. Guard with an upgrade tag so the rewrite runs once per version that changes the file. + +See sample: `reapply-resource-instructions-on-upgrade.good.al`. + +## Anti Pattern + +Editing only the resource file, or calling `SetInstructions` solely from the first-time setup path. Detection signal: instruction resource in `resourceFolders` with no upgrade procedure that re-applies it. + +See sample: `reapply-resource-instructions-on-upgrade.bad.al`. diff --git a/community/knowledge/agents/register-copilot-capability-for-the-agent.bad.al b/community/knowledge/agents/register-copilot-capability-for-the-agent.bad.al new file mode 100644 index 0000000..eaf6440 --- /dev/null +++ b/community/knowledge/agents/register-copilot-capability-for-the-agent.bad.al @@ -0,0 +1,21 @@ +enumextension 50100 "Sales Review Agent Metadata" extends "Agent Metadata Provider" +{ + value(50100; "Sales Review Agent") + { + Caption = 'Sales Review Agent'; + Implementation = IAgentFactory = "Sales Review Agent Factory", + IAgentMetadata = "Sales Review Agent Metadata", + IAgentTaskExecution = "Sales Review Agent Task"; + } +} + +codeunit 50101 "Sales Review Agent Install" +{ + Subtype = Install; + Access = Internal; + + trigger OnInstallAppPerDatabase() + begin + // Agent type exists, but no Copilot Capability value and no RegisterCapability. + end; +} diff --git a/community/knowledge/agents/register-copilot-capability-for-the-agent.good.al b/community/knowledge/agents/register-copilot-capability-for-the-agent.good.al new file mode 100644 index 0000000..ba940d0 --- /dev/null +++ b/community/knowledge/agents/register-copilot-capability-for-the-agent.good.al @@ -0,0 +1,26 @@ +enumextension 50101 "Sales Review Agent Copilot" extends "Copilot Capability" +{ + value(50101; "Sales Review Agent") + { + Caption = 'Sales Review Agent'; + } +} + +codeunit 50101 "Sales Review Agent Install" +{ + Subtype = Install; + Access = Internal; + + trigger OnInstallAppPerDatabase() + var + CopilotCapability: Codeunit "Copilot Capability"; + LearnMoreUrlTxt: Label 'https://example.com/sales-review-agent', Locked = true; + begin + if not CopilotCapability.IsCapabilityRegistered(Enum::"Copilot Capability"::"Sales Review Agent") then + CopilotCapability.RegisterCapability( + Enum::"Copilot Capability"::"Sales Review Agent", + Enum::"Copilot Availability"::Preview, + Enum::"Copilot Billing Type"::"Microsoft Billed", + LearnMoreUrlTxt); + end; +} diff --git a/community/knowledge/agents/register-copilot-capability-for-the-agent.md b/community/knowledge/agents/register-copilot-capability-for-the-agent.md new file mode 100644 index 0000000..79dfe44 --- /dev/null +++ b/community/knowledge/agents/register-copilot-capability-for-the-agent.md @@ -0,0 +1,30 @@ +--- +bc-version: [27..] +domain: agents +keywords: [copilot-capability, registercapability, install, feature-switch, enumextension] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Register a Copilot capability for the agent on install + +## Description + +Each agent type needs a `Copilot Capability` enum value that the factory links as the feature switch and billing surface. The capability is invisible on Copilot and agent capabilities until an install codeunit calls `RegisterCapability` when it is not already registered. Unique ordinals matter across installed apps. Models often extend `Agent Metadata Provider` and never register the capability. + +## Best Practice + +Extend `Copilot Capability` with a unique value. In `OnInstallAppPerDatabase`, call `Copilot Capability.IsCapabilityRegistered` and, if false, `RegisterCapability` with availability, billing type, and a learn-more URL. Point `IAgentFactory` at that capability. + +See sample: `register-copilot-capability-for-the-agent.good.al`. + +## Anti Pattern + +Shipping the agent enum without a `Copilot Capability` value, or adding the enum but never calling `RegisterCapability`. Duplicate ordinals across extensions also collide. Detection signal: agent metadata provider with no matching capability registration in an install codeunit. + +See sample: `register-copilot-capability-for-the-agent.bad.al`. + +## See also + +`wire-all-three-agent-interfaces.md` covers registration of the provider implementation that references this capability. diff --git a/community/knowledge/agents/set-instructions-as-secrettext.bad.al b/community/knowledge/agents/set-instructions-as-secrettext.bad.al new file mode 100644 index 0000000..734f0b2 --- /dev/null +++ b/community/knowledge/agents/set-instructions-as-secrettext.bad.al @@ -0,0 +1,19 @@ +codeunit 50100 "Sales Review Agent Create" +{ + procedure CreateWithInstructions() + var + Agent: Codeunit Agent; + TempAgentAccessControl: Record "Agent Access Control" temporary; + AgentUserSecurityId: Guid; + InstructionsLbl: Label 'You are a sales validation agent. Check credit.', Locked = true; + begin + AgentUserSecurityId := Agent.Create( + Enum::"Agent Metadata Provider"::"Sales Review Agent", + 'SALESREVIEW', + 'Sales Review Agent', + TempAgentAccessControl); + // Label/text is not SecretText and is type-wide, not per instance. + Agent.SetInstructions(AgentUserSecurityId, InstructionsLbl); + Agent.Activate(AgentUserSecurityId); + end; +} diff --git a/community/knowledge/agents/set-instructions-as-secrettext.good.al b/community/knowledge/agents/set-instructions-as-secrettext.good.al new file mode 100644 index 0000000..8bedf69 --- /dev/null +++ b/community/knowledge/agents/set-instructions-as-secrettext.good.al @@ -0,0 +1,20 @@ +codeunit 50100 "Sales Review Agent Create" +{ + procedure CreateWithInstructions() + var + Agent: Codeunit Agent; + TempAgentAccessControl: Record "Agent Access Control" temporary; + AgentUserSecurityId: Guid; + Instructions: SecretText; + InstructionsNameTxt: Label 'Instructions.txt', Locked = true; + begin + AgentUserSecurityId := Agent.Create( + Enum::"Agent Metadata Provider"::"Sales Review Agent", + 'SALESREVIEW', + 'Sales Review Agent', + TempAgentAccessControl); + Instructions := NavApp.GetResourceAsText(InstructionsNameTxt); + Agent.SetInstructions(AgentUserSecurityId, Instructions); + Agent.Activate(AgentUserSecurityId); + end; +} diff --git a/community/knowledge/agents/set-instructions-as-secrettext.md b/community/knowledge/agents/set-instructions-as-secrettext.md new file mode 100644 index 0000000..0f246f6 --- /dev/null +++ b/community/knowledge/agents/set-instructions-as-secrettext.md @@ -0,0 +1,26 @@ +--- +bc-version: [27..] +domain: agents +keywords: [setinstructions, secrettext, instructions, per-instance, resource] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Set agent instructions as SecretText on the instance + +## Description + +Instructions are instance data, not an enum caption. `Agent.SetInstructions` takes `SecretText` so the payload is not logged or copied as ordinary text. A Label or plaintext Text on the agent type is the wrong store: it leaks into telemetry-friendly strings and cannot vary per instance or company. + +## Best Practice + +Load instruction text from a resource or builder into a `SecretText` variable and call `Agent.SetInstructions(AgentUserSecurityId, Instructions)` after `Create`. Keep one instruction document per instance. + +See sample: `set-instructions-as-secrettext.good.al`. + +## Anti Pattern + +Passing a `Label` or `Text` to `SetInstructions`, storing instructions in a setup Text field without wrapping as `SecretText`, or putting the prompt only in a code comment. Detection signal: `SetInstructions` with a non-`SecretText` argument, or no `SetInstructions` after `Create`. + +See sample: `set-instructions-as-secrettext.bad.al`. diff --git a/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.bad.al b/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.bad.al new file mode 100644 index 0000000..570ca09 --- /dev/null +++ b/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.bad.al @@ -0,0 +1,36 @@ +codeunit 50100 "Sales Review Agent Factory" +{ + procedure ShowCanCreateAgent(): Boolean + begin + // Author intends this to forbid all creates. It only hides the UI tile. + exit(false); + end; +} + +pageextension 50100 "Sales Order List Agent Create" extends "Sales Order List" +{ + actions + { + addlast(Processing) + { + action(CreateAgent) + { + ApplicationArea = All; + Caption = 'Create review agent'; + + trigger OnAction() + var + Agent: Codeunit Agent; + TempAgentAccessControl: Record "Agent Access Control" temporary; + begin + // Still succeeds for any caller with permission to run this action. + Agent.Create( + Enum::"Agent Metadata Provider"::"Sales Review Agent", + 'SALESREVIEW', + 'Sales Review Agent', + TempAgentAccessControl); + end; + } + } + } +} diff --git a/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.good.al b/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.good.al new file mode 100644 index 0000000..4601f1d --- /dev/null +++ b/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.good.al @@ -0,0 +1,25 @@ +codeunit 50100 "Sales Review Agent Factory" +{ + procedure ShowCanCreateAgent(): Boolean + var + AgentSystemPermissions: Codeunit "Agent System Permissions"; + begin + // Hides the type from non-admins in the UI. Does not block Agent.Create. + exit(AgentSystemPermissions.CurrentUserHasCanManageAllAgentsPermission()); + end; + + procedure CreateIfAllowed() + var + Agent: Codeunit Agent; + AgentSystemPermissions: Codeunit "Agent System Permissions"; + TempAgentAccessControl: Record "Agent Access Control" temporary; + begin + if not AgentSystemPermissions.CurrentUserHasCanManageAllAgentsPermission() then + Error('Only agent administrators can create this agent.'); + Agent.Create( + Enum::"Agent Metadata Provider"::"Sales Review Agent", + 'SALESREVIEW', + 'Sales Review Agent', + TempAgentAccessControl); + end; +} diff --git a/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.md b/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.md new file mode 100644 index 0000000..1eb151d --- /dev/null +++ b/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.md @@ -0,0 +1,26 @@ +--- +bc-version: [28..] +domain: agents +keywords: [showcancreateagent, agent-discovery, agent-create, administrator, agent-configuration-rights] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ShowCanCreateAgent only hides UI create, not programmatic create + +## Description + +`IAgentFactory.ShowCanCreateAgent` controls whether the type appears in the in-client create UI. Returning false does not stop `Agent.Create` from AL. From 28.1, non-admins can discover extension agents unless this method (and agent configuration rights) restrict them. Models treat a false return as a hard create lock. + +## Best Practice + +Use `ShowCanCreateAgent` to decide discovery. If only agent administrators should see the type, return `Agent System Permissions.CurrentUserHasCanManageAllAgentsPermission`. Enforce extra policy inside your own create API. Never assume UI hiding blocks code. + +See sample: `show-can-create-agent-does-not-block-code-create.good.al`. + +## Anti Pattern + +Returning `exit(false)` from `ShowCanCreateAgent` and then documenting that instances cannot be created, while page actions or other apps still call `Agent.Create`. Detection signal: `ShowCanCreateAgent` always false with no matching guard on programmatic create. + +See sample: `show-can-create-agent-does-not-block-code-create.bad.al`. diff --git a/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.bad.al b/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.bad.al new file mode 100644 index 0000000..8a184c6 --- /dev/null +++ b/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.bad.al @@ -0,0 +1,15 @@ +codeunit 50100 "Sales Review Agent Tasks" +{ + procedure EnqueueFromEmailBody(RawEmailBody: Text; AgentUserSecurityId: Guid) + var + AgentTaskBuilder: Codeunit "Agent Task Builder"; + AgentTaskMessageBuilder: Codeunit "Agent Task Message Builder"; + AgentTask: Record "Agent Task"; + begin + AgentTaskMessageBuilder.Initialize('Internet', RawEmailBody) + .SetRequiresReview(false); + AgentTask := AgentTaskBuilder.Initialize(AgentUserSecurityId, 'Process inbound mail') + .AddTaskMessage(AgentTaskMessageBuilder) + .Create(); + end; +} diff --git a/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.good.al b/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.good.al new file mode 100644 index 0000000..394aeb2 --- /dev/null +++ b/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.good.al @@ -0,0 +1,16 @@ +codeunit 50100 "Sales Review Agent Tasks" +{ + procedure EnqueueFromSalesOrder(SalesHeader: Record "Sales Header"; AgentUserSecurityId: Guid) + var + AgentTaskBuilder: Codeunit "Agent Task Builder"; + AgentTaskMessageBuilder: Codeunit "Agent Task Message Builder"; + AgentTask: Record "Agent Task"; + begin + SalesHeader.TestField("No."); + AgentTaskMessageBuilder.Initialize('Sales Team', 'Review sales order ' + SalesHeader."No.") + .SetRequiresReview(false); + AgentTask := AgentTaskBuilder.Initialize(AgentUserSecurityId, 'Review Sales Order') + .AddTaskMessage(AgentTaskMessageBuilder) + .Create(); + end; +} diff --git a/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.md b/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.md new file mode 100644 index 0000000..449038f --- /dev/null +++ b/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.md @@ -0,0 +1,26 @@ +--- +bc-version: [28..] +domain: agents +keywords: [setrequiresreview, agent-task-message-builder, approval, trusted-input, skip-review] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Skip incoming message review only after the caller validated the payload + +## Description + +Incoming task messages default to requiring user approval before the agent runs. From 28.1, `Agent Task Message Builder.SetRequiresReview(false)` starts the agent immediately. That is safe only for inputs you already validated in AL (your page action, your posting subscriber). External email or partner payloads are not trusted by default. Analysis Warnings still force a review. + +## Best Practice + +Leave the default review-on for anything that originated outside your extension. Call `SetRequiresReview(false)` only on messages you constructed from already-authorized BC data. + +See sample: `skip-incoming-review-only-for-trusted-input.good.al`. + +## Anti Pattern + +`SetRequiresReview(false)` on simulated email, incoming webhooks, or user-free text. Detection signal: `SetRequiresReview(false)` next to external content with no prior validation. + +See sample: `skip-incoming-review-only-for-trusted-input.bad.al`. diff --git a/community/knowledge/agents/use-documented-instruction-keywords.bad.al b/community/knowledge/agents/use-documented-instruction-keywords.bad.al new file mode 100644 index 0000000..0854a3d --- /dev/null +++ b/community/knowledge/agents/use-documented-instruction-keywords.bad.al @@ -0,0 +1,7 @@ +codeunit 50100 "Sales Review Agent Instr." +{ + procedure GetInstructions() Instructions: SecretText + begin + Instructions := 'When done, email the customer and remember the credit limit. Click Post_Promoted.'; + end; +} diff --git a/community/knowledge/agents/use-documented-instruction-keywords.good.al b/community/knowledge/agents/use-documented-instruction-keywords.good.al new file mode 100644 index 0000000..e03adee --- /dev/null +++ b/community/knowledge/agents/use-documented-instruction-keywords.good.al @@ -0,0 +1,13 @@ +codeunit 50100 "Sales Review Agent Instr." +{ + procedure GetInstructions() Instructions: SecretText + var + Builder: TextBuilder; + begin + Builder.AppendLine('When the sales order is ready, request a review before posting.'); + Builder.AppendLine('If a field is missing, ask for assistance.'); + Builder.AppendLine('Memorize the customer credit limit for later steps.'); + Builder.AppendLine('When confirmed, write an email to the salesperson; outbound mail is reviewed.'); + Instructions := Builder.ToText(); + end; +} diff --git a/community/knowledge/agents/use-documented-instruction-keywords.md b/community/knowledge/agents/use-documented-instruction-keywords.md new file mode 100644 index 0000000..2150a5d --- /dev/null +++ b/community/knowledge/agents/use-documented-instruction-keywords.md @@ -0,0 +1,30 @@ +--- +bc-version: [27..] +domain: agents +keywords: [instruction-keywords, request-a-review, memorize, write-an-email, invoke-action] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use the toolkit instruction keywords for review, mail, and memory + +## Description + +The agent runtime looks for specific phrases: ask for assistance, request a review, reply, write an email, memorize, `Set field`, use lookup, `Invoke action`. Ordinary English such as get a human to look or remember this is weaker. Outbound reply and email always require review; that is platform policy, not optional tone. + +## Best Practice + +In the instruction resource, use those keywords at the decision points: request a review before posting; write an email only after stating that outbound mail is reviewed; memorize values the later steps need. Pair `Reply` / `Write an email` with an explicit review sentence. + +See sample: `use-documented-instruction-keywords.good.al`. + +## Anti Pattern + +Inventing tool-like verbs (call Copilot, click Post_Promoted) or omitting request a review before posting. Detection signal: instruction text that says email the customer with no review keyword. + +See sample: `use-documented-instruction-keywords.bad.al`. + +## See also + +`instruction-structure-is-role-rules-steps.md` defines the containing document structure, while `instructions-describe-work-not-tool-ids.md` keeps outcomes independent of UI tool identifiers. diff --git a/community/knowledge/agents/wire-all-three-agent-interfaces.bad.al b/community/knowledge/agents/wire-all-three-agent-interfaces.bad.al new file mode 100644 index 0000000..e4d4bcb --- /dev/null +++ b/community/knowledge/agents/wire-all-three-agent-interfaces.bad.al @@ -0,0 +1,9 @@ +enumextension 50100 "Sales Review Agent Metadata" extends "Agent Metadata Provider" +{ + value(50100; "Sales Review Agent") + { + Caption = 'Sales Review Agent'; + // Only factory is bound. Metadata UI and task execution never resolve. + Implementation = IAgentFactory = "Sales Review Agent Factory"; + } +} diff --git a/community/knowledge/agents/wire-all-three-agent-interfaces.good.al b/community/knowledge/agents/wire-all-three-agent-interfaces.good.al new file mode 100644 index 0000000..bd13f65 --- /dev/null +++ b/community/knowledge/agents/wire-all-three-agent-interfaces.good.al @@ -0,0 +1,10 @@ +enumextension 50100 "Sales Review Agent Metadata" extends "Agent Metadata Provider" +{ + value(50100; "Sales Review Agent") + { + Caption = 'Sales Review Agent'; + Implementation = IAgentFactory = "Sales Review Agent Factory", + IAgentMetadata = "Sales Review Agent Meta. Impl.", + IAgentTaskExecution = "Sales Review Agent Task"; + } +} diff --git a/community/knowledge/agents/wire-all-three-agent-interfaces.md b/community/knowledge/agents/wire-all-three-agent-interfaces.md new file mode 100644 index 0000000..d3ce4b2 --- /dev/null +++ b/community/knowledge/agents/wire-all-three-agent-interfaces.md @@ -0,0 +1,30 @@ +--- +bc-version: [27..] +domain: agents +keywords: [agent-metadata-provider, iagentfactory, iagentmetadata, iagenttaskexecution, enumextension, implementation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Wire all three agent interfaces on the metadata provider + +## Description + +An AL agent type is registered by extending `Agent Metadata Provider`. The platform locates factory, metadata, and task-execution behaviour only through the `Implementation` property on that enum value. Omitting `IAgentFactory`, `IAgentMetadata`, or `IAgentTaskExecution` leaves create, UI identity, or task runs unbound. Models often ship a single codeunit and skip the enum wiring. + +## Best Practice + +On the enum value, set `Implementation` for all three interfaces, each pointing at a dedicated codeunit. Keep factory (create, defaults, first-time setup), metadata (setup page, summary, annotations), and task execution (message analysis, intervention suggestions) in separate objects. + +See sample: `wire-all-three-agent-interfaces.good.al`. + +## Anti Pattern + +An `Agent Metadata Provider` value with no `Implementation`, only one interface mapped, or all three interfaces pointing at one catch-all codeunit that cannot satisfy the contracts. Detection signal: enumextension of `Agent Metadata Provider` whose value does not list `IAgentFactory`, `IAgentMetadata`, and `IAgentTaskExecution`. + +See sample: `wire-all-three-agent-interfaces.bad.al`. + +## See also + +`register-copilot-capability-for-the-agent.md` covers the feature capability linked by the factory implementation. diff --git a/community/skills/review/al-agents-review.md b/community/skills/review/al-agents-review.md new file mode 100644 index 0000000..4bc2a6b --- /dev/null +++ b/community/skills/review/al-agents-review.md @@ -0,0 +1,70 @@ +--- +kind: action-skill +id: al-agents-review +version: 1 +title: AL agents review +description: Reviews AL source changes against agent guidance from BCQuality. +inputs: [pr-diff, file-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL agents review + +Reviews AL source changes against the `agents` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is not one of the skills composed by `al-code-review`; Entry discovers and dispatches it as a top-level peer, so it produces an independent findings report. + +Agent findings apply to AL files that implement or invoke Agent SDK surfaces, including agent interfaces, setup, creation, task execution, capability registration, profiles, access controls, instructions, and session-bound subscribers. Return `not-applicable` when the diff contains no AL changes or no Agent SDK implementation or usage. + +An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces one JSON document conforming to the DO output contract. + +## Source + +Read the root `knowledge-index.json` generated by Entry and select entries whose `domain` is `agents` across every enabled layer. Use index metadata for candidate selection and open an article body only after it enters the worklist. + +## Relevance + +Apply READ's frontmatter matching semantics to the target BC version, AL technology, countries, and application areas. If a dimension is unknown, retain conditionally applicable guidance only when configuration permits it; cap resulting confidence at `medium` and name the unknown dimension in the finding message. + +## Worklist + +Match changed objects, procedures, interfaces, and tokens against article keywords, titles, descriptions, and paths. Give particular weight to: + +- Implementations of `IAgentFactory`, `IAgentMetadata`, and `IAgentTaskExecution`. +- Agent setup tables and `ConfigurationDialog` pages using `Agent Setup`, `Agent Setup Buffer`, or `Agent Setup Part`. +- Agent creation, upgrade, capability registration, profile configuration, access controls, and subscriber binding. +- Instruction construction, `SecretText`, documented instruction keywords, task messages, trusted input, warnings, errors, and review behavior. +- Public APIs invoked by agent tasks across app boundaries. + +Use these targeted rules to avoid broad token-only matches: + +- Worklist setup-page shape guidance when the page returned by agent metadata is not a `ConfigurationDialog` or omits `Agent Setup Part`. +- Worklist temporary-source guidance when setup writes occur before a non-Cancel close path or a setup page is not temporary. +- Worklist permission guidance when default access controls are broad or when code assumes an agent can exceed the assigning user's permissions. +- Worklist instruction guidance only for text used as agent instructions; do not flag unrelated prompts, labels, or user-facing help. +- Worklist session-binding guidance only when subscribers are bound outside an agent session or left bound after execution. + +After selection, resolve conflicting guidance using READ's layer precedence. Record displaced candidates in `suppressed` with `reason: "layer-precedence"`; record disabled-layer candidates with `reason: "configuration"`. + +An empty worklist caused by absent applicable knowledge produces `no-knowledge`. An empty worklist caused by no match produces `completed` with no findings. + +## Action + +Evaluate each worklisted article's `## Best Practice` and `## Anti Pattern` against the changed code: + +- Emit `major` for a clear anti-pattern and `minor` for a concrete best-practice contradiction. +- Use `blocker` only when the article identifies a violated platform guarantee. +- Do not emit a finding from applicability alone. +- Set confidence to `high` for unambiguous syntax or identifier evidence, `medium` for heuristic or conditionally applicable evidence, and `low` only for an explicit advisory. + +Agent-originated findings without a matching article must follow the DO contract: prefix the ID with `agent:`, use `references: []`, cap severity at `minor` and confidence at `medium`, and emit only concrete defects within the agents domain. + +Provide `suggested-code` when the repair is small, local, and unambiguous. Otherwise, when a mechanical-looking repair depends on missing context or has multiple valid forms, set `suggested-code-omission-reason`. + +Use the standard DO outcomes: `completed`, `no-knowledge`, `not-applicable`, `partial`, or `failed`. + +## Output + +Return only one JSON document conforming to the DO output contract. Every finding emitted by this skill MUST set `findings[].domain` to `"Agents"`. Knowledge-backed finding IDs and references MUST use the exact repository-relative article path from the knowledge index. \ No newline at end of file diff --git a/evaluation/README.md b/evaluation/README.md index cd25d3d..7063baf 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -1,6 +1,6 @@ # AL review evaluation -The evaluation is convention-driven. For every `microsoft/skills/review/al--review.md` leaf, the harness finds `microsoft/knowledge//`, selects the first article (by filename) with both `.bad.al` and `.good.al` companions, and derives the expected positive and clean control automatically. Adding a conforming leaf requires no scoring-contract edit. +The evaluation is convention-driven. The harness discovers every `/skills/review/al--review.md` leaf across the enabled `microsoft`, `community`, and `custom` layers. Duplicate domains resolve with `custom > community > microsoft` precedence. For each selected leaf, the harness finds paired knowledge across the same layers, applies the same precedence to duplicate article slugs, selects the first article (by filename) with both `.bad.al` and `.good.al` companions, and derives the expected positive and clean control automatically. Adding a conforming leaf requires no scoring-contract edit. `review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different article or add context when the generic convention cannot express a scenario. It should remain empty in the normal case. @@ -12,7 +12,7 @@ Model-facing preparation hashes case IDs, neutralizes `Good`/`Bad` object-name t pwsh ./tools/Test-ReviewFixtures.ps1 -Root . ``` -This credential-free check proves every registered leaf maps to a same-named knowledge domain with at least one complete AL sample pair and that all configured overrides are valid. +This credential-free check proves every selected leaf maps to a same-named knowledge domain with at least one complete AL sample pair and that all configured overrides are valid. ## Run a fast-model evaluation @@ -26,7 +26,7 @@ This credential-free check proves every registered leaf maps to a same-named kno 2. For a fast/small model, use one fresh invocation per `request-case-*.json`. Each request embeds the exact leaf instructions, that domain's candidate index rows with authoritative paths, and one opaque case. The model opens only matching articles and copies finding IDs from `candidateArticles[].path`. Save each response with the matching `result-case-*.json` name in the same directory. - `request-.json` files provide optional two-case leaf batches; save those as `result-.json`. Directory scoring prefers `result-case-*.json` when present and otherwise falls back to `result-*.json`. `review-request.json` is an optional all-domains stress test for larger models. Neither batch form is the preferred fast-model profile. + `request-.json` files provide optional two-case leaf batches and identify the selected layer-owned skill path; save those as `result-.json`. Directory scoring prefers `result-case-*.json` when present and otherwise falls back to `result-*.json`. `review-request.json` is an optional all-domains stress test for larger models. Neither batch form is the preferred fast-model profile. 3. Save only this result shape: diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 2f85d5c..3b6426c 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -4,6 +4,9 @@ "minimumExpectedRecall": 1.0, "minimumCleanRate": 1.0, "overrides": { + "agents": { + "article": "wire-all-three-agent-interfaces" + }, "appsource": { "context": "AppSourceCop mandatoryAffixes is configured to ABC." }, diff --git a/tools/Test-ReviewFixtures.ps1 b/tools/Test-ReviewFixtures.ps1 index 3f3f144..a9912b7 100644 --- a/tools/Test-ReviewFixtures.ps1 +++ b/tools/Test-ReviewFixtures.ps1 @@ -109,12 +109,44 @@ if (([double]$manifest.minimumCleanRate -lt 0) -or ([double]$manifest.minimumCle $problems.Add('minimumCleanRate must be between 0 and 1.') | Out-Null } -$leafDomains = @( - Get-ChildItem -LiteralPath (Join-Path $Root 'microsoft/skills/review') -File -Filter 'al-*-review.md' | - Where-Object Name -ne 'al-code-review.md' | - ForEach-Object { $_.BaseName -replace '^al-', '' -replace '-review$', '' } | - Sort-Object -Unique +$layers = @( + [pscustomobject]@{ Name = 'microsoft'; Rank = 1 } + [pscustomobject]@{ Name = 'community'; Rank = 2 } + [pscustomobject]@{ Name = 'custom'; Rank = 3 } ) +$layerRanks = @{} +foreach ($layer in $layers) { + $layerRanks[[string]$layer.Name] = [int]$layer.Rank +} +$leafCandidates = @( + foreach ($layer in $layers) { + $reviewDirectory = Join-Path $Root "$($layer.Name)/skills/review" + if (-not (Test-Path -LiteralPath $reviewDirectory -PathType Container)) { + continue + } + Get-ChildItem -LiteralPath $reviewDirectory -File -Filter 'al-*-review.md' | + Where-Object Name -ne 'al-code-review.md' | + ForEach-Object { + [pscustomobject]@{ + Domain = $_.BaseName -replace '^al-', '' -replace '-review$', '' + Layer = $layer.Name + Rank = $layer.Rank + RelativePath = [System.IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/') + } + } + } +) +$leafSkills = @( + $leafCandidates | + Group-Object Domain | + ForEach-Object { $_.Group | Sort-Object Rank -Descending | Select-Object -First 1 } | + Sort-Object Domain +) +$leafDomains = @($leafSkills | ForEach-Object Domain) +$leafByDomain = @{} +foreach ($leafSkill in $leafSkills) { + $leafByDomain[[string]$leafSkill.Domain] = $leafSkill +} $overrides = @{} if ($manifest.PSObject.Properties.Name -contains 'overrides') { @@ -130,9 +162,35 @@ foreach ($overrideDomain in $overrides.Keys) { $caseList = [System.Collections.Generic.List[object]]::new() foreach ($domain in $leafDomains) { - $knowledgeDirectory = Join-Path $Root "microsoft/knowledge/$domain" - if (-not (Test-Path -LiteralPath $knowledgeDirectory -PathType Container)) { - $problems.Add("${domain}: no Microsoft knowledge directory exists.") | Out-Null + $articleCandidates = @( + foreach ($layer in $layers) { + $knowledgeDirectory = Join-Path $Root "$($layer.Name)/knowledge/$domain" + if (-not (Test-Path -LiteralPath $knowledgeDirectory -PathType Container)) { + continue + } + Get-ChildItem -LiteralPath $knowledgeDirectory -File -Filter '*.md' | + Where-Object { + (Test-Path -LiteralPath (Join-Path $knowledgeDirectory "$($_.BaseName).good.al") -PathType Leaf) -and + (Test-Path -LiteralPath (Join-Path $knowledgeDirectory "$($_.BaseName).bad.al") -PathType Leaf) + } | + ForEach-Object { + [pscustomobject]@{ + BaseName = $_.BaseName + File = $_ + Rank = $layer.Rank + ArticlePath = [System.IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/') + } + } + } + ) + $articles = @( + $articleCandidates | + Group-Object BaseName | + ForEach-Object { $_.Group | Sort-Object Rank -Descending | Select-Object -First 1 } | + Sort-Object BaseName + ) + if (-not $articles.Count) { + $problems.Add("${domain}: no enabled knowledge layer has an article with both .good.al and .bad.al companion samples.") | Out-Null continue } @@ -143,27 +201,33 @@ foreach ($domain in $leafDomains) { if ($articleName.EndsWith('.md')) { $articleName = [System.IO.Path]::GetFileNameWithoutExtension($articleName) } - $candidate = Join-Path $knowledgeDirectory "$articleName.md" - if (Test-Path -LiteralPath $candidate -PathType Leaf) { - $selectedArticle = Get-Item -LiteralPath $candidate - } else { - $problems.Add("${domain}: override article does not exist: $articleName.md") | Out-Null + $selectedArticle = $articles | Where-Object BaseName -eq $articleName | Select-Object -First 1 + if (-not $selectedArticle) { + $articleExists = @( + foreach ($layer in $layers) { + $articleFile = Join-Path $Root "$($layer.Name)/knowledge/$domain/$articleName.md" + if (Test-Path -LiteralPath $articleFile -PathType Leaf) { + $articleFile + } + } + ).Count -gt 0 + if ($articleExists) { + $problems.Add("${domain}: override article does not have both .good.al and .bad.al companion samples: $articleName.md") | Out-Null + } else { + $problems.Add("${domain}: override article does not exist: $articleName.md") | Out-Null + } + continue } } else { - $selectedArticle = Get-ChildItem -LiteralPath $knowledgeDirectory -File -Filter '*.md' | - Sort-Object Name | - Where-Object { - (Test-Path -LiteralPath (Join-Path $knowledgeDirectory "$($_.BaseName).good.al") -PathType Leaf) -and - (Test-Path -LiteralPath (Join-Path $knowledgeDirectory "$($_.BaseName).bad.al") -PathType Leaf) - } | - Select-Object -First 1 + $selectedArticle = $articles | Select-Object -First 1 } if (-not $selectedArticle) { $problems.Add("${domain}: no article has both .good.al and .bad.al companion samples.") | Out-Null continue } - $articlePath = "microsoft/knowledge/$domain/$($selectedArticle.Name)" + $articlePath = [string]$selectedArticle.ArticlePath + $sampleDirectory = (Split-Path -Parent $articlePath).Replace('\', '/') $context = if ($override -and ($override.PSObject.Properties.Name -contains 'context')) { [string]$override.context } else { @@ -173,7 +237,7 @@ foreach ($domain in $leafDomains) { $case = [pscustomobject]@{ id = "$domain-$kind" domain = $domain - input = "microsoft/knowledge/$domain/$($selectedArticle.BaseName).$kind.al" + input = "$sampleDirectory/$($selectedArticle.BaseName).$kind.al" expected = if ($kind -eq 'bad') { @($articlePath) } else { @() } } if ($context) { @@ -188,7 +252,7 @@ $seenIds = [System.Collections.Generic.HashSet[string]]::new([System.StringCompa foreach ($case in $cases) { $id = [string]$case.id $domain = [string]$case.domain - $input = [string]$case.input + $inputRelativePath = [string]$case.input $expected = @($case.expected) if ([string]::IsNullOrWhiteSpace($id)) { @@ -200,15 +264,15 @@ foreach ($case in $cases) { $problems.Add("${id}: domain '$domain' has no registered al-$domain-review leaf.") | Out-Null } - $inputPath = Join-Path $Root $input + $inputPath = Join-Path $Root $inputRelativePath if (-not (Test-Path -LiteralPath $inputPath -PathType Leaf)) { - $problems.Add("${id}: input does not exist: $input") | Out-Null + $problems.Add("${id}: input does not exist: $inputRelativePath") | Out-Null } - if ($expected.Count -and $input -notmatch '\.bad\.[^.]+$') { - $problems.Add("${id}: positive case must use a .bad sample: $input") | Out-Null + if ($expected.Count -and $inputRelativePath -notmatch '\.bad\.[^.]+$') { + $problems.Add("${id}: positive case must use a .bad sample: $inputRelativePath") | Out-Null } - if (-not $expected.Count -and $input -notmatch '\.good\.[^.]+$') { - $problems.Add("${id}: clean case must use a .good sample: $input") | Out-Null + if (-not $expected.Count -and $inputRelativePath -notmatch '\.good\.[^.]+$') { + $problems.Add("${id}: clean case must use a .good sample: $inputRelativePath") | Out-Null } foreach ($reference in $expected) { @@ -218,7 +282,7 @@ foreach ($case in $cases) { } } if ($expected.Count) { - $sampleSlug = ([System.IO.Path]::GetFileName($input) -replace '\.(?:good|bad)\.[^.]+$', '') + $sampleSlug = ([System.IO.Path]::GetFileName($inputRelativePath) -replace '\.(?:good|bad)\.[^.]+$', '') $primarySlug = [System.IO.Path]::GetFileNameWithoutExtension([string]$expected[0]) if ($sampleSlug -ne $primarySlug) { $problems.Add("${id}: primary expected article '$primarySlug' must match sample slug '$sampleSlug'.") | Out-Null @@ -311,9 +375,16 @@ if ($PrepareDirectory) { } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $PrepareDirectory 'review-request.json') -Encoding UTF8 foreach ($domain in $leafDomains) { - $domainArticles = @($fullIndex.articles | Where-Object domain -eq $domain) + $domainArticles = @( + $fullIndex.articles | + Where-Object domain -eq $domain | + Sort-Object @{ Expression = { $layerRanks[[string]$_.layer] }; Descending = $true }, path | + Group-Object { [System.IO.Path]::GetFileName([string]$_.path) } | + ForEach-Object { $_.Group | Select-Object -First 1 } | + Sort-Object path + ) $domainIndexName = "index-$domain.json" - $leafPath = "microsoft/skills/review/al-$domain-review.md" + $leafPath = [string]$leafByDomain[$domain].RelativePath $leafFullText = Get-Content -LiteralPath (Join-Path $Root $leafPath) -Raw $leafInstructions = @($leafFullText -split '(?m)^## Output\s*\r?\n', 2)[0] $leafInstructions += "`n## Output`nReturn only the request's resultSchema." From 82422f94c9f4307ef2b8d5f1d66ba70c4115ae52 Mon Sep 17 00:00:00 2001 From: Kilian Seizinger <56249171+pri-kise@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:07:18 +0200 Subject: [PATCH 60/86] Avoid Public Event publisher (#144) * Avoid Public Event publisher * knowledge(events): scope public-publisher detection to same-app raisers The detection rule flagged every public event publisher, including ones deliberately public so a sibling app can raise them - a contract `internal` cannot express across app boundaries. Scope the finding to publishers that are public although only their own app raises them, and record the cross-app case as a valid Best Practice option. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0b67b90d-e4b4-4b92-9684-726c72c43b3f --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0b67b90d-e4b4-4b92-9684-726c72c43b3f --- ...-event-publishers-local-or-internal.bad.al | 43 ++++++++++++++ ...event-publishers-local-or-internal.good.al | 59 +++++++++++++++++++ ...lare-event-publishers-local-or-internal.md | 42 +++++++++++++ ...raising-events-inside-try-functions.bad.al | 2 +- ...aising-events-inside-try-functions.good.al | 2 +- ...r-attribute-scopes-explicit-commits.bad.al | 2 +- ...-attribute-scopes-explicit-commits.good.al | 2 +- 7 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 community/knowledge/events/declare-event-publishers-local-or-internal.bad.al create mode 100644 community/knowledge/events/declare-event-publishers-local-or-internal.good.al create mode 100644 community/knowledge/events/declare-event-publishers-local-or-internal.md diff --git a/community/knowledge/events/declare-event-publishers-local-or-internal.bad.al b/community/knowledge/events/declare-event-publishers-local-or-internal.bad.al new file mode 100644 index 0000000..faf873c --- /dev/null +++ b/community/knowledge/events/declare-event-publishers-local-or-internal.bad.al @@ -0,0 +1,43 @@ +// Demonstration only. Shows the wrong pattern: the publisher carries no access modifier, so it is +// public - which never was what lets extensions subscribe. + +codeunit 50100 "Loyalty Points Mgt Bad" +{ + procedure AwardPoints(CustomerNo: Code[20]; SalesAmount: Decimal) + var + Points: Decimal; + IsHandled: Boolean; + begin + Points := SalesAmount / 10; + + IsHandled := false; + OnBeforeAwardPoints(CustomerNo, Points, IsHandled); + if IsHandled then + exit; + + // ... insert the loyalty entry ... + end; + + // BAD: no access modifier, so this publisher is public. Public access does not enable + // subscription - it enables raising. Narrowing it to internal after release breaks callers, + // so the widening cannot be walked back cheaply. + [IntegrationEvent(false, false)] + procedure OnBeforeAwardPoints(CustomerNo: Code[20]; var Points: Decimal; var IsHandled: Boolean) + begin + end; +} + +codeunit 50101 "Loyalty Points Caller Bad" +{ + procedure FirePublisherDirectly(CustomerNo: Code[20]) + var + LoyaltyPointsMgt: Codeunit "Loyalty Points Mgt Bad"; + Points: Decimal; + IsHandled: Boolean; + begin + // Compiles only because the publisher is public. Every subscriber runs although no points + // were ever awarded, on a Points value nobody computed, and the IsHandled answer the + // subscribers write is read by no one. + LoyaltyPointsMgt.OnBeforeAwardPoints(CustomerNo, Points, IsHandled); + end; +} diff --git a/community/knowledge/events/declare-event-publishers-local-or-internal.good.al b/community/knowledge/events/declare-event-publishers-local-or-internal.good.al new file mode 100644 index 0000000..70064e5 --- /dev/null +++ b/community/knowledge/events/declare-event-publishers-local-or-internal.good.al @@ -0,0 +1,59 @@ +// Demonstration only. Shows the correct pattern: a public facade codeunit whose event publishers +// are internal, so only the implementation codeunit decides when they fire. + +codeunit 50100 "Loyalty Points Mgt Good" +{ + procedure AwardPoints(CustomerNo: Code[20]; SalesAmount: Decimal) + var + LoyaltyPointsImpl: Codeunit "Loyalty Points Impl Good"; + begin + LoyaltyPointsImpl.AwardPoints(CustomerNo, SalesAmount); + end; + + // internal, not public: the implementation codeunit raises this and nobody else. Subscribers + // bind through Codeunit::"Loyalty Points Mgt Good", which is public by default - that object + // access is all a subscriber in another extension needs. + [IntegrationEvent(false, false)] + internal procedure OnBeforeAwardPoints(CustomerNo: Code[20]; var Points: Decimal; var IsHandled: Boolean) + begin + end; + + [IntegrationEvent(false, false)] + internal procedure OnAfterAwardPoints(CustomerNo: Code[20]; Points: Decimal) + begin + end; +} + +codeunit 50101 "Loyalty Points Impl Good" +{ + Access = Internal; + + procedure AwardPoints(CustomerNo: Code[20]; SalesAmount: Decimal) + var + LoyaltyPointsMgt: Codeunit "Loyalty Points Mgt Good"; + Points: Decimal; + IsHandled: Boolean; + begin + Points := SalesAmount / 10; + + IsHandled := false; + LoyaltyPointsMgt.OnBeforeAwardPoints(CustomerNo, Points, IsHandled); + if IsHandled then + exit; + + // ... insert the loyalty entry ... + + LoyaltyPointsMgt.OnAfterAwardPoints(CustomerNo, Points); + end; +} + +codeunit 50102 "Loyalty Points Sub Good" +{ + // The shape a subscriber in a dependent extension takes: it names the public object, and is + // indifferent to the publisher being internal. + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Loyalty Points Mgt Good", 'OnAfterAwardPoints', '', false, false)] + local procedure LogAwardedPointsOnAfterAwardPoints(CustomerNo: Code[20]; Points: Decimal) + begin + // ... write telemetry ... + end; +} diff --git a/community/knowledge/events/declare-event-publishers-local-or-internal.md b/community/knowledge/events/declare-event-publishers-local-or-internal.md new file mode 100644 index 0000000..9d097b4 --- /dev/null +++ b/community/knowledge/events/declare-event-publishers-local-or-internal.md @@ -0,0 +1,42 @@ +--- +bc-version: [all] +domain: events +keywords: [event-publisher, access-modifier, local, internal, integration-event, business-event, subscriber, breaking-change] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Declare event publishers local or internal + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +The access modifier on an `[IntegrationEvent]` or `[BusinessEvent]` publisher controls who may *raise* the procedure, not who may *subscribe* to it. A subscriber in a dependent extension binds through the object named in its `[EventSubscriber(...)]` attribute, so the only accessibility a foreign subscriber needs is on the *object* — a codeunit left at its default public access. The publisher procedure itself can and should stay `local` or `internal`. Publishing an event is an invitation to subscribe, not an invitation to call: an omitted access modifier makes the publisher public, which hands every dependent extension the ability to fire the event on its own. The signature-compatibility consequences of a shipped publisher are covered separately by `treat-local-and-internal-events-as-subscriber-contracts`. + +## Applies to + +Ordinary `[IntegrationEvent]` and `[BusinessEvent]` publishers. `[InternalEvent]` has its own module-only visibility semantics, and external business events are out of scope. + +## Best Practice + +Give an event publisher the narrowest access modifier that still lets the code owning the operation raise it: + +- `local` when only the declaring object raises the event. This is the common case and the default choice. +- `internal` when another object in the same app raises it — typically an internal implementation codeunit raising an event declared on a public facade codeunit. The facade object stays public so dependent extensions can name it in `[EventSubscriber(...)]`; the publisher stays `internal` so only the implementation decides when the event fires. +- `public` only when a *different app* must raise the event — a hub or event-bus codeunit in a foundation app that sibling apps signal through, where `internal` cannot reach across the app boundary. This is a deliberate caller contract, not a concession to subscribers, and it is maintained like any other public API. + +Subscribers are unaffected by any of these choices. A non-public publisher also keeps the freedom to add a parameter later, which a public publisher gives up — see `add-new-event-parameters-at-the-end`. + +See sample: `declare-event-publishers-local-or-internal.good.al`. + +## Anti Pattern + +An event publisher declared with no access modifier — or widened to public — in the belief that dependent extensions need that to subscribe. They do not. Two consequences follow. Any dependent extension can now call the publisher directly, firing every subscriber outside the owning routine's control flow, on state the publisher never prepared and with an `IsHandled` answer nobody reads. And because the publisher is a public procedure, it is a caller contract: narrowing it back to `local` or `internal` after release is itself a breaking change, so the mistake is not cheaply reversible. + +Detection: an `[IntegrationEvent]` or `[BusinessEvent]` publisher that is public although every raiser is in its own app — typically raised only from its declaring object. A publisher deliberately made public so another app can raise it is not this anti-pattern; do not report it. When the surrounding repository or API context does not reveal whether an external raiser is intended, treat the public modifier as intentional rather than reporting it. + +The mirror-image anti-pattern belongs to the reviewer, human or agent: recommending that a publisher be made public so extensions can subscribe, or reporting a `local`/`internal` publisher as unreachable dead code. Both readings mistake raising for subscribing. Neither should be raised as a finding. + +See sample: `declare-event-publishers-local-or-internal.bad.al`. diff --git a/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.bad.al b/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.bad.al index 4dfce97..5b4c1c0 100644 --- a/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.bad.al +++ b/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.bad.al @@ -3,7 +3,7 @@ codeunit 50116 "Payment Processor Bad" { [IntegrationEvent(false, false)] - procedure OnBeforeSubmitPayment(var PaymentAmount: Decimal; var Cancel: Boolean) + local procedure OnBeforeSubmitPayment(var PaymentAmount: Decimal; var Cancel: Boolean) begin end; diff --git a/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.good.al b/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.good.al index 7c76303..10f331c 100644 --- a/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.good.al +++ b/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.good.al @@ -3,7 +3,7 @@ codeunit 50114 "Payment Processor" { [IntegrationEvent(false, false)] - procedure OnBeforeSubmitPayment(var PaymentAmount: Decimal; var Cancel: Boolean) + local procedure OnBeforeSubmitPayment(var PaymentAmount: Decimal; var Cancel: Boolean) begin end; diff --git a/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.bad.al b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.bad.al index c3b796b..4633488 100644 --- a/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.bad.al +++ b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.bad.al @@ -1,7 +1,7 @@ codeunit 50151 "Sec Sample CommitBeh Bad" { [IntegrationEvent(true, false)] - procedure OnBeforeApplyingDiscount(var Customer: Record Customer) + local procedure OnBeforeApplyingDiscount(var Customer: Record Customer) begin 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 index 0204d12..f0f3233 100644 --- a/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.good.al +++ b/microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.good.al @@ -2,7 +2,7 @@ codeunit 50149 "Sec Sample CommitBeh Good" { [CommitBehavior(CommitBehavior::Ignore)] [IntegrationEvent(true, false)] - procedure OnBeforeApplyingDiscount(var Customer: Record Customer) + local procedure OnBeforeApplyingDiscount(var Customer: Record Customer) begin end; From 2439d5c41243705d91e1a5e194abdcb142eb946d Mon Sep 17 00:00:00 2001 From: waldo Date: Wed, 2 Sep 2026 16:26:24 +0200 Subject: [PATCH 61/86] knowledge(web-services): under schema 2.0 an API enum field is a contract by member name, under 1.0 by caption (#149) What an API page publishes for an enum field depends on the OData schema version: member names under 2.0, the caption as Edm.String under 1.0, never the ordinal. Custom APIs defaulted to 1.0 through BC 23 and to 2.0 from BC 24. LLMs assert one carrier as universal and get one direction wrong. Co-authored-by: waldo1001 <12088142+waldo1001@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- ...-are-a-contract-by-name-not-ordinal.bad.al | 52 ++++++++++++++++++ ...are-a-contract-by-name-not-ordinal.good.al | 54 +++++++++++++++++++ ...lues-are-a-contract-by-name-not-ordinal.md | 44 +++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.bad.al create mode 100644 community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.good.al create mode 100644 community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md diff --git a/community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.bad.al b/community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.bad.al new file mode 100644 index 0000000..9b5ad06 --- /dev/null +++ b/community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.bad.al @@ -0,0 +1,52 @@ +// Ordinal 2 was renamed from CreditNote to CreditMemo with ordinal and caption kept. The compiler stays silent +// and AS0082 fires only against a baseline; every schema 2.0 consumer that filters on or posts CreditNote fails. +enum 50120 "Document Kind Bad" +{ + Extensible = true; + + value(0; Invoice) { Caption = 'Invoice'; } + value(1; Order) { Caption = 'Order'; } + value(2; CreditMemo) { Caption = 'Credit Memo'; } +} + +table 50121 "Document Header Bad" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { DataClassification = CustomerContent; } + field(2; Kind; Enum "Document Kind Bad") { DataClassification = CustomerContent; } + } + + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +page 50122 "Document API Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'documents'; + APIVersion = 'v1.0'; + EntityName = 'document'; + EntitySetName = 'documents'; + ODataKeyFields = SystemId; + SourceTable = "Document Header Bad"; + DelayedInsert = true; + + layout + { + area(content) + { + repeater(records) + { + field(id; Rec.SystemId) { Caption = 'id'; Editable = false; } + field(number; Rec."No.") { Caption = 'number'; } + field(kind; Rec.Kind) { Caption = 'kind'; } + } + } + } +} diff --git a/community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.good.al b/community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.good.al new file mode 100644 index 0000000..ef3337b --- /dev/null +++ b/community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.good.al @@ -0,0 +1,54 @@ +// Neither the name CreditNote nor its caption changes in place: schema 2.0 consumers bind to the name, +// schema 1.0 consumers to the caption. A new kind is appended; a retired kind is obsoleted, never deleted. +enum 50120 "Document Kind Good" +{ + Extensible = true; + + value(0; Invoice) { Caption = 'Invoice'; } + value(1; Order) { Caption = 'Order'; } + value(2; CreditNote) { Caption = 'Credit Note'; } + value(3; ReturnOrder) { Caption = 'Return Order'; } + value(4; Quote) { Caption = 'Quote'; ObsoleteState = Pending; ObsoleteReason = 'Quotes moved to the quotes API.'; ObsoleteTag = '3.0'; } +} + +table 50121 "Document Header Good" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { DataClassification = CustomerContent; } + field(2; Kind; Enum "Document Kind Good") { DataClassification = CustomerContent; } + } + + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +page 50122 "Document API Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'documents'; + APIVersion = 'v1.0'; + EntityName = 'document'; + EntitySetName = 'documents'; + ODataKeyFields = SystemId; + SourceTable = "Document Header Good"; + DelayedInsert = true; + + layout + { + area(content) + { + repeater(records) + { + field(id; Rec.SystemId) { Caption = 'id'; Editable = false; } + field(number; Rec."No.") { Caption = 'number'; } + field(kind; Rec.Kind) { Caption = 'kind'; } + } + } + } +} diff --git a/community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md b/community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md new file mode 100644 index 0000000..7988326 --- /dev/null +++ b/community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md @@ -0,0 +1,44 @@ +--- +bc-version: [17..] +domain: web-services +keywords: [api-page, enum, enum-value-name, rename, ordinal, caption, schemaversion, breaking-change, dataverse, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Under OData schema version 2.0 an API enum field is a contract by member name; under 1.0 it is the caption + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +What an API page publishes for an enum field depends on the OData `$schemaversion` the caller receives, and never on the ordinal. Under schema 2.0 the field is a strongly typed enum: `$metadata`, every response and every `$filter` carry the AL member **names**, and captions are published separately through `entityDefinitions`. Under schema 1.0 the same field is `Edm.String` and responses carry the en-US **caption**. Microsoft's API v2.0 is always schema 2.0. Custom APIs defaulted to schema 1.0 through BC 23; BC 24 changed the default to 2.0, and a caller can still pin `?$schemaversion=1.0`. Dataverse virtual tables build on API v2.0 and match choices by the value's External Name, with the integer values documented as not stable. + +LLMs treat one carrier as universal. Some assume the caption is serialised and report every caption change as an API break; others assume the name is serialised and wave a rename through when its ordinal and caption are kept. Each is right for one schema version and wrong for the other, and neither knows that the schema version decides. Page-shape changes are covered by `version-apis-by-adding-not-mutating-published-versions.md`; ordinal stability for persisted rows by `enum-values-additive-at-end.md`. This article is about the values inside one exposed field. + +## Best Practice + +Establish which schema versions the field is served under before changing anything about its enum. Under schema 2.0 (Microsoft's API v2.0, an explicit `$schemaversion=2.0` in the consumer contract, or another reliable context signal) the member name is the contract: keep names stable, put wording changes in `Caption`, add a value by appending a new name with an ordinal above every existing one, and retire a value through `ObsoleteState` rather than by deleting it. For a custom API that clients may still call as schema 1.0, any install of BC 17 to 23 or a caller that pins 1.0, the caption is a contract as well: change neither name nor caption in place, or publish the change as a new `APIVersion` on a new page object. A rename is out in every case: AppSourceCop AS0082 rejects it against a baseline, and dependent extensions bind to the name. + +See sample: `api-enum-values-are-a-contract-by-name-not-ordinal.good.al`. + +## Anti Pattern + +Renaming a value on an enum that an API page field exposes while keeping its ordinal and caption, or re-pointing an API page field at a source field whose enum carries different member names. Under schema 2.0 every consumer that filters on, posts, or maps the old name fails at runtime and Dataverse choices built on the old External Name stop matching; AS0082 reports the rename only when AppSourceCop runs against a baseline package, and nothing reports the re-pointed field. + +Detection signal: a diff hunk that changes the name in a `value(...)` line while keeping its ordinal, on an enum used by a table field that a `PageType = API` page exposes; or an API page `field(...)` whose source expression moves to a field of another enum type. + +The mirror image is a review defect: suppressing a caption-change finding because "the API serialises names". That holds only under schema 2.0. Do not flag a `Caption` change when the reviewer can establish schema 2.0 for every consumer; on a custom API where clients may select schema 1.0, report a caption change on an exposed value as a consumer-visible change and ask for versioning. A value appended at the end changes no contract under either schema and is never a finding. + +See sample: `api-enum-values-are-a-contract-by-name-not-ordinal.bad.al`. + +## See also + +Deprecated features in the platform, Schema version for custom APIs (changed default in BC 24) — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/upgrade/deprecated-features-platform#changes-in-2024-release-wave-1-version-240 + +Transitioning from API v1.0 to API v2.0, Enums and Schema version — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/api-reference/v2.0/transition-to-api-v2.0#enums + +Working with Virtual Tables, Table fields — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/powerplatform/powerplat-entity-modeling#table-fields + +AppSourceCop AS0082 (rename) — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/analyzers/appsourcecop-as0082 and AS0083 (delete) — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/analyzers/appsourcecop-as0083 From bca8f478d88eb9e54cdfde1f14e1051e0c46b790 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 3 Sep 2026 10:25:44 +0200 Subject: [PATCH 62/86] Simplify standalone AL code review skill (#150) * Simplify standalone AL review skill Rename the host-facing skill to al-code-review, reduce it to a thin Entry adapter, document the architecture, and validate host skill metadata. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: af96bb3d-893a-48a2-8298-c8f4271c162c * Anchor plugin paths at PLUGIN_ROOT and restore the layer-filter caveat The adapter delegated index preparation to Entry's Preparation step, but that step is written for the clone model: it runs `pwsh ./tools/Build-KnowledgeIndex.ps1` from the checkout root. A plugin host's working directory is the user's own project, so the path does not resolve and the index is never built. Because knowledge-index.json is gitignored, a fresh install has none, and READ silently degrades to path-based discovery. The adapter now resolves Entry's repo-relative paths against PLUGIN_ROOT and names the absolute index build; the generator resolves its own root, so it indexes and writes the right tree from any cwd. Entry also asserted that pruning has always happened before it runs, which is false for an installation that ships the whole tree. Entry now scopes that guarantee to consumers that actually prune, and the caveat dropped in the rewrite - that enabled-layers narrows discovery rather than denying access - is restored in the adapter and summarized in the README. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0b67b90d-e4b4-4b92-9684-726c72c43b3f --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: af96bb3d-893a-48a2-8298-c8f4271c162c Copilot-Session: 0b67b90d-e4b4-4b92-9684-726c72c43b3f --- .claude-plugin/marketplace.json | 6 +- .github/scripts/validate_frontmatter.py | 33 +++++++- README.md | 46 +++++++++++ agent-consumption.md | 14 ++++ plugin.json | 6 +- skills/README.md | 34 +++++++- skills/al-code-review/SKILL.md | 69 ++++++++++++++++ skills/bcquality-al-review/SKILL.md | 100 ------------------------ skills/entry.md | 4 +- 9 files changed, 203 insertions(+), 109 deletions(-) create mode 100644 skills/al-code-review/SKILL.md delete mode 100644 skills/bcquality-al-review/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1aa47c6..752ae8d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,10 +8,10 @@ { "name": "bcquality", "source": "./", - "description": "Business Central AL quality knowledge base and review skills, packaged as an installable plugin. Ships the entire BCQuality tree (skills, knowledge, tools) so the Entry routing protocol runs against the installed clone.", - "version": "0.1.0", + "description": "Business Central AL quality knowledge base and review skills, packaged as an installable plugin. Exposes an AL review adapter while preserving BCQuality's internal Entry and action-skill protocols.", + "version": "0.2.0", "skills": [ - "./skills/bcquality-al-review/" + "./skills/" ] } ] diff --git a/.github/scripts/validate_frontmatter.py b/.github/scripts/validate_frontmatter.py index dd3ef4e..b0076cc 100644 --- a/.github/scripts/validate_frontmatter.py +++ b/.github/scripts/validate_frontmatter.py @@ -42,6 +42,7 @@ ACTION_SKILL_OPTIONAL_KEYS = { } META_SKILL_REQUIRED_KEYS = {"kind", "id", "version", "title"} ENTRY_SKILL_REQUIRED_KEYS = {"kind", "id", "version", "title"} +HOST_SKILL_REQUIRED_KEYS = {"name", "description"} STANDARD_INPUTS = { "pr-diff", "object-list", "file-path", "repository", "telemetry-query", @@ -444,10 +445,36 @@ def validate_entry_skill(path: Path, parsed: Parsed, report: Report) -> None: report.error(path, "R23", f"version must be a positive integer: {v!r}", 1) +def validate_host_skill(path: Path, parsed: Parsed, report: Report) -> None: + if parsed.frontmatter_error: + report.error(path, "R01", parsed.frontmatter_error, 1) + return + fm = parsed.frontmatter + assert fm is not None + missing = HOST_SKILL_REQUIRED_KEYS - fm.keys() + if missing: + report.error(path, "R29", f"missing required host-skill keys: {sorted(missing)}", 1) + + name = fm.get("name") + if not isinstance(name, str) or not name: + report.error(path, "R29", "host-skill name must be a non-empty string", 1) + else: + if len(name) > 64 or not KEBAB_CASE.fullmatch(name): + report.error(path, "R29", f"host-skill name must be lowercase kebab-case and at most 64 characters: '{name}'", 1) + if name != path.parent.name: + report.error(path, "R29", f"host-skill name must match parent directory '{path.parent.name}', got '{name}'", 1) + + description = fm.get("description") + if not isinstance(description, str) or not description: + report.error(path, "R29", "host-skill description must be a non-empty string", 1) + elif len(description) > 1024: + report.error(path, "R29", "host-skill description must be at most 1024 characters", 1) + + # --- Path and sample checks ------------------------------------------------- def classify(path_from_root: Path) -> str | None: - """Return 'knowledge' | 'action-skill' | 'meta' | 'entry' | None.""" + """Return 'knowledge' | 'action-skill' | 'host-skill' | 'meta' | 'entry' | None.""" parts = path_from_root.parts if len(parts) < 2: return None @@ -459,6 +486,8 @@ def classify(path_from_root: Path) -> str | None: return "entry" if name in META_SKILL_FILES: return "meta" + if len(parts) == 3 and parts[2] == "SKILL.md": + return "host-skill" return None if top in LAYERS and path_from_root.suffix == ".md": if len(parts) >= 3 and parts[1] == "skills": @@ -616,6 +645,8 @@ def run(root: Path) -> Report: validate_entry_skill(path, parsed, report) if parsed.frontmatter and isinstance(parsed.frontmatter.get("id"), str): skill_records.append(SkillRecord(path, "entry-point", parsed.frontmatter["id"])) + elif kind == "host-skill": + validate_host_skill(path, parsed, report) # Second pass: sample files per knowledge domain for layer in LAYERS: diff --git a/README.md b/README.md index 8946d9c..7933725 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,52 @@ Skills define how agents consume knowledge. They come in three flavors: An orchestrator (such as AL-Go) points the agent at BCQuality's URL and provides a task context. The agent's first call is `/skills/entry.md`, which returns a dispatch record naming the action skill(s) to invoke. The agent then invokes each dispatched skill in turn, reading READ and DO on demand. No prior knowledge of BCQuality's structure is baked into the orchestrator — only the convention *"invoke `/skills/entry.md` first."* +### Standalone plugin installation + +BCQuality can also be installed directly as a plugin. The plugin registers one +host-native skill, +[`al-code-review`](skills/al-code-review/SKILL.md), which adapts the caller's +request to the same Entry protocol used by orchestrators. + +For GitHub Copilot CLI: + +```shell +copilot plugin install microsoft/BCQuality +``` + +Plugin version `0.2.0` renamed the former `bcquality-al-review` skill to +`al-code-review`; explicit invocations and allowlists using the old skill name +must be updated. The name remains distinct from BC-ALAgents' public +`al-review` skill because current hosts may load plugin skill names into one +shared inventory. + +The adapter is intentionally not a second review implementation: + +```text +standalone host skill: skills/al-code-review/SKILL.md + -> routing contract: skills/entry.md + -> review coordinator: microsoft/skills/review/al-code-review.md + -> domain review leaves +``` + +Only the first file follows the host's `SKILL.md` packaging format. The +remaining files are BCQuality's internal protocol and layered action skills. +Entry remains the single owner of routing and index preparation; +`al-code-review.md` remains the single owner of broad-review composition. This +separation keeps standalone installation available without duplicating those +policies in the plugin adapter. + +Note that a plugin install ships the entire tree, so `BCQUALITY_ENABLED_LAYERS` +narrows discovery without removing any files. Layer selection is a filter here, +not a deny mechanism — see [the adapter](skills/al-code-review/SKILL.md) for the +difference from the pruned-clone model. + +The host adapter and internal action skill intentionally share the +`al-code-review` name: they expose the same operation in two different skill +formats. Their paths make the boundary explicit. The adapter lives under +`skills/al-code-review/SKILL.md`; the internal Microsoft-layer coordinator +lives at `microsoft/skills/review/al-code-review.md`. + ## Knowledge file format Every knowledge file is a markdown file with mandatory YAML frontmatter. Files target under 100 lines (ideal under 50). If two ideas would share a file, split them. diff --git a/agent-consumption.md b/agent-consumption.md index 8db80d6..61eb3c9 100644 --- a/agent-consumption.md +++ b/agent-consumption.md @@ -12,6 +12,10 @@ For the high-level framing and repo structure, start with the [README](README.md - **Global skills** in `/skills/` — the `entry.md` entry-point skill plus the READ · DO · WRITE contracts that govern the rest of the repo. - **Layer content** in `/microsoft/`, `/community/`, and `/custom/` — knowledge files and action skills grouped by authority. +When BCQuality is installed as a standalone plugin, it additionally exposes +`skills/al-code-review/SKILL.md`. This is a host-format adapter, not another +action skill: it creates the task context and enters the same flow at Entry. + ## The flow ```mermaid @@ -31,6 +35,12 @@ The orchestrator has a URL setting that points at BCQuality (default: `github.co ### 2. Agent invokes Entry The agent reads `/skills/entry.md` and runs it against the task context. Entry applies its Source → Relevance → Worklist → Action steps over the action skills under `*/skills/**/*.md` and returns a **dispatch record**: the set of action skills to invoke, plus a list of candidates it skipped (with reasons). Routing is a skill, not orchestrator logic. +For a standalone plugin installation, the host activates the +`skills/al-code-review/SKILL.md` adapter first. That adapter preserves the +caller's actual goal, constructs the task context, and invokes Entry. It does +not select the internal `microsoft/skills/review/al-code-review.md` action skill +itself or duplicate Entry's preparation, routing, and failure semantics. + ### 3. Agent consumes the dispatch record 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. @@ -89,6 +99,10 @@ Orchestrators MUST tolerate an absent `domain` in reports from older producers. ## 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. +- **Standalone installation adds an adapter, not another policy layer.** The + plugin's host-format `al-code-review` skill only translates the invocation + into Entry's task context. Entry and the dispatched action skills remain + authoritative. - **Layers decide authority, not code.** The agent sees `/microsoft/` and `/community/` together; if two files conflict, the precedence rule defined in READ resolves it. A partner fork can disable `/community/` — that's a config choice, not a code change. - **Knowledge and skills evolve independently.** A new knowledge file requires no skill changes — existing skills pick it up via frontmatter filters. A new skill requires no knowledge changes — it sources from what's already there. diff --git a/plugin.json b/plugin.json index 0934bd3..c99785b 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "name": "bcquality", - "description": "Quality skills and knowledge for Business Central development. Exposes a review bridge skill that drives the BCQuality Entry protocol over the installed knowledge base.", - "version": "0.1.0", + "description": "Quality skills and knowledge for Business Central development. Exposes a standalone AL review adapter backed by BCQuality's Entry protocol.", + "version": "0.2.0", "author": { "name": "microsoft/BCQuality", "url": "https://github.com/microsoft/BCQuality" @@ -16,6 +16,6 @@ "quality" ], "skills": [ - "./skills/bcquality-al-review/" + "./skills/" ] } diff --git a/skills/README.md b/skills/README.md index d0500a3..3d8fdfb 100644 --- a/skills/README.md +++ b/skills/README.md @@ -1,6 +1,9 @@ # BCQuality global skills -This folder contains the skills that are not owned by any single layer. There are two kinds: +This folder contains BCQuality's layer-independent protocol files and the +host-native adapter used by standalone plugin installations. + +The protocol files have two kinds: - **The entry-point skill** — the first skill an agent invokes at runtime. - **The three meta-skill contracts** — stable references that define what the rest of BCQuality means. @@ -23,6 +26,35 @@ Routing logic lives in Entry, not in the orchestrator. An agent that knows only READ and DO are read on demand — typically by the first action skill the agent executes after dispatch. They are not prerequisites for invoking Entry. WRITE is only used when scaffolding new content. +## Standalone plugin adapter + +| Path | Role | +|---|---| +| [`al-code-review/SKILL.md`](al-code-review/SKILL.md) | Exposes BCQuality through the standard `SKILL.md` format when this repository is installed as a plugin. | + +The adapter is deliberately thin. It translates the caller's request into an +Entry task context, then follows Entry's dispatch without owning routing, +review, index, or output policy. It is not an action skill, is not considered +by Entry, and should not accumulate behavior already defined by `entry.md`, +`read.md`, `do.md`, or a layered action skill. + +This gives the two skill formats distinct roles: + +- `skills/al-code-review/SKILL.md` is the public host integration surface for a + standalone plugin installation. +- `microsoft/skills/review/al-code-review.md` is BCQuality's internal + Microsoft-layer super-skill for coordinating a broad AL review. + +The host adapter and internal coordinator deliberately share the +`al-code-review` name because they represent the same user-facing operation in +their respective formats. Their locations distinguish their roles. The +adapter remains distinct from BC-ALAgents' separately installed `al-review` +skill, avoiding a collision in hosts that use one shared skill inventory. The +reference from the adapter to Entry, and from a dispatched super-skill to its +leaf skills, is intentional progressive disclosure. It avoids registering +every internal BCQuality protocol file as an ambient host skill while allowing +each review domain to run in an isolated context. + These contracts are stable. Changes require a PR approved by both maintainers. For the end-to-end flow — from orchestrator trigger through to findings integration — see [`../agent-consumption.md`](../agent-consumption.md). For the high-level project framing, see [`../README.md`](../README.md). diff --git a/skills/al-code-review/SKILL.md b/skills/al-code-review/SKILL.md new file mode 100644 index 0000000..991a771 --- /dev/null +++ b/skills/al-code-review/SKILL.md @@ -0,0 +1,69 @@ +--- +name: al-code-review +description: Review Business Central AL code changes using BCQuality's curated rules. Use for an AL pull request, working-tree diff, branch, or individual AL file when BCQuality is installed as a standalone plugin. +--- + +# AL code review + +This is BCQuality's host-native adapter for standalone plugin installations. It +is not a BCQuality action skill and contains no review or routing policy. Its +only responsibility is to translate the caller's request into an Entry task +context and execute the resulting dispatch. + +## Execute + +1. Resolve `PLUGIN_ROOT` to the directory containing this plugin's root + `plugin.json`. This file is + `PLUGIN_ROOT/skills/al-code-review/SKILL.md`; when the host does not expose + the plugin root, resolve it two levels above this file. +2. Build the `task-context` required by + `PLUGIN_ROOT/skills/entry.md`: + - Copy the caller's actual request verbatim into `goal`; do not replace a + focused request such as "review performance" with a generic full-review + goal. + - Set `inputs-available` to the inputs actually available to the review, + normally `pr-diff` for changes or `file-path` for one file. + - Set `technologies: [al]` when the input is known to be AL. + - Pass `bc-version`, `countries`, and `application-area` only when supplied + or reliably determined. + - If `BCQUALITY_ENABLED_LAYERS` is set, split its comma-separated value and + pass the trimmed, non-empty entries as `enabled-layers`; otherwise omit the + field and let Entry apply its default. + - If `BCQUALITY_DISABLED_SKILLS` is set, split its comma-separated value and + pass the trimmed, non-empty entries as `disabled-skills`; otherwise omit + the field. +3. Read and execute `PLUGIN_ROOT/skills/entry.md` exactly as written, including + its Preparation step. Entry is authoritative for index freshness, routing, + defaults, and failure behavior; this adapter must not duplicate or weaken + those rules. Entry is written for a checkout whose root is the current + directory, so resolve every repo-relative path it names against + `PLUGIN_ROOT` rather than the caller's working directory, which is the + user's own project. In particular, run Preparation's index build as + `pwsh PLUGIN_ROOT/tools/Build-KnowledgeIndex.ps1`: the generator resolves + its own root and writes `PLUGIN_ROOT/knowledge-index.json`, which is not + shipped and is therefore absent on a fresh install. If `pwsh` is + unavailable or the build fails, continue — READ falls back to path-based + discovery — but do not treat a failed build as a failed review. +4. Follow Entry's **How the agent uses the dispatch** instructions. Invoke only + the returned action skills, pass each dispatch entry's exact input subset, + and read `PLUGIN_ROOT/skills/read.md` and `PLUGIN_ROOT/skills/do.md` on + demand. When a dispatched super-skill requests isolated leaf execution and + the host supports child contexts, use them. +5. Return each dispatched action skill's findings report unchanged. If Entry + returns `no-match` or `failed`, return its dispatch record unchanged. + +The internal `microsoft/skills/review/al-code-review.md` action skill remains +the canonical coordinator for a broad AL review. Entry decides whether that +super-skill or a narrower domain skill applies; this host adapter never chooses +between them. + +## Layer selection is not a deny mechanism + +A plugin install ships the whole BCQuality tree, so `enabled-layers` here can +only narrow *discovery*: the files of a layer left out of the list still exist +on disk. This differs from the clone model Entry's Preparation step describes, +where a consumer prunes its checkout to policy before the agent runs and the +index is rebuilt over the pruned tree. Treat `BCQUALITY_ENABLED_LAYERS` as a +selection filter, never as a security boundary. A host that needs a genuine +deny mechanism must prune the installed tree itself. + diff --git a/skills/bcquality-al-review/SKILL.md b/skills/bcquality-al-review/SKILL.md deleted file mode 100644 index 32c8207..0000000 --- a/skills/bcquality-al-review/SKILL.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -name: bcquality-al-review -description: Review Business Central AL code changes using the BCQuality knowledge base. Use when reviewing an AL pull request, a working-tree diff, or a single AL file, and you want findings backed by BCQuality's curated, BC-specific quality rules. ---- - -# BCQuality AL review - -This skill drives the BCQuality **Entry protocol** over the knowledge base that ships -inside this plugin. It is the plugin entry point for consumers (orchestrators, CLIs) -that do not already know BCQuality's internal conventions — the only convention they -need is "invoke this skill for an AL review." - -BCQuality itself is orchestrator-agnostic content: knowledge files plus routing and -action skills. This bridge is the thin consumer glue that lets a plugin host run that -content without hardcoding BCQuality's layout. - -## When to use - -- Reviewing an AL pull request or an uncommitted working-tree diff. -- Reviewing a single AL file. -- Any task whose goal is "review Business Central / AL code for quality issues." - -Do **not** use this skill to *generate* AL code — it only reviews. - -## Plugin root - -Resolve `PLUGIN_ROOT` to the directory that contains this plugin's root -`plugin.json`. This skill lives at -`PLUGIN_ROOT/skills/bcquality-al-review/SKILL.md`, so `PLUGIN_ROOT` is two levels up -from this file. All paths below are relative to `PLUGIN_ROOT`. If the host exposes a -plugin-root environment variable, prefer it. - -## Steps - -1. **Refresh the knowledge index (best effort).** If `pwsh` is available, run - `pwsh PLUGIN_ROOT/tools/Build-KnowledgeIndex.ps1` from `PLUGIN_ROOT` to (re)generate - `PLUGIN_ROOT/knowledge-index.json` over the installed tree. This is a discovery - accelerator only — if `pwsh` is missing or the build fails, continue; the review - skills fall back to path-based discovery. - -2. **Run Entry.** Read `PLUGIN_ROOT/skills/entry.md` and execute it against a - task context describing the review: - - ```yaml - task-context: - goal: "Review the AL changes for quality issues" - inputs-available: [pr-diff] # or [file-path] for single-file review - technologies: [al] - enabled-layers: [microsoft, community, custom] # see "Layer selection" below - ``` - - **Layer selection.** `enabled-layers` defaults to all three layers. A host can - narrow it by setting the `BCQUALITY_ENABLED_LAYERS` environment variable to a - comma-separated subset (e.g. `microsoft` or `microsoft,community`); when set, pass - exactly those layers instead of the default. This is the plugin path's only knob - for layer policy — see the limitation in Notes. - - Fill `bc-version`, `countries`, and `application-area` only when the caller - supplies them; omit them otherwise (an omitted dimension is unconstrained). - -3. **Follow the dispatch record.** Entry returns a dispatch record naming the action - skill(s) to invoke — for a PR review this is normally - `microsoft/skills/review/al-code-review.md`. For each dispatched skill, read the - file and execute its Source → Relevance → Worklist → Action steps, reading - `PLUGIN_ROOT/skills/read.md` and `PLUGIN_ROOT/skills/do.md` on demand. - When `al-code-review` composes its leaves and the host supports child contexts or - separate model calls, run each leaf in an isolated context and roll up the returned - JSON. Pass each call the exact index rows for that leaf's domain so references can - be copied verbatim. This is the preferred execution profile for fast/small models; - do not force one generation to retain all domain knowledge at once. - -4. **Emit findings.** Produce the rolled-up findings report in the DO output contract, - including each review finding's producer-supplied `domain` label (`outcome`, - `findings`, `references`, `confidence`, `suppressed`). Do not invent a different - shape; downstream consumers parse the DO contract without skill-specific logic. - Apply DO's reference-integrity gate before returning: every knowledge-backed path - must exist in the installed tree, must have been opened in full, and must be copied - verbatim. Never synthesize a plausible article slug. - -If Entry returns `no-match` or `failed`, return the dispatch record unchanged so the -caller can log the reason. - -## Notes - -- This skill adds nothing to BCQuality's knowledge or routing logic; it only bootstraps - the existing Entry protocol from a plugin host. Knowledge and skill changes belong in - the layers under `PLUGIN_ROOT/microsoft/`, `PLUGIN_ROOT/community/`, and - `PLUGIN_ROOT/custom/`, not here. -- **Layer pruning is coarser than the URL/clone model.** In the clone model a consumer - prunes its checkout to policy *before* the agent runs, and the knowledge index is - rebuilt over the pruned tree, so a denied layer can never leak into discovery. A - plugin install ships the whole tree, so this bridge can only *narrow discovery* via - `enabled-layers` (`BCQUALITY_ENABLED_LAYERS`) — the denied layers' files still exist on - disk. Treat `enabled-layers` as a selection filter, not a hard security boundary. A - future revision could add a genuine deny mechanism (e.g. pruning the installed tree). -- **Manifest location.** This plugin's manifest is the root `plugin.json`, which both - Claude Code and Copilot CLI accept (verified with Copilot CLI: `plugin install` - reports the bridge skill loaded). A `.claude-plugin/marketplace.json` alongside it - carries the marketplace entry. Claude Code also reads `.claude-plugin/plugin.json`; if - a future host only reads that form, dual-home the manifest there. diff --git a/skills/entry.md b/skills/entry.md index f094aa6..0196c35 100644 --- a/skills/entry.md +++ b/skills/entry.md @@ -35,7 +35,7 @@ task-context: ## Preparation — knowledge index -Before routing, ensure the knowledge index is current for the **live** clone. The dispatched review skills read `knowledge-index.json` (at the clone root) at their Source step instead of opening every knowledge file — see READ's [Retrieval workflow](read.md). Because a consumer prunes its clone to policy *before* the agent runs, the index MUST be built over the clone as it exists now, so it lists exactly the articles that survived pruning and never an article the consumer denied: +Before routing, ensure the knowledge index is current for the **live** clone. The dispatched review skills read `knowledge-index.json` (at the clone root) at their Source step instead of opening every knowledge file — see READ's [Retrieval workflow](read.md). When a consumer prunes its clone to policy *before* the agent runs, the index MUST be built over the clone as it exists now, so it lists exactly the articles that survived pruning and never an article the consumer denied: - If `knowledge-index.json` is absent — or you cannot confirm it reflects the current knowledge tree — regenerate it by running, from the checkout root: @@ -44,6 +44,8 @@ Before routing, ensure the knowledge index is current for the **live** clone. Th ``` It defaults to indexing this checkout and writes `knowledge-index.json` at the root in well under a second. When in doubt, rebuild: a sub-second rebuild is always cheaper than a stale or over-listing index, which is a correctness risk. +- The paths above assume the checkout root is the current directory. A caller that enters Entry from elsewhere — a plugin host, whose working directory is the user's own project — MUST resolve them against the BCQuality root it already knows instead. The generator resolves its own root, so invoking it by absolute path indexes and writes the right tree. +- Pruning is the consumer's job, not Entry's, and not every consumer does it: an installation that ships the whole tree gets no deny guarantee from this step. There, `enabled-layers` narrows discovery only, and the unlisted layers' files remain on disk. - This is a side step. It MUST NOT change Entry's output — the dispatch record below is the only thing Entry emits, and build logs are never part of the dispatch JSON. Generation is **owned by BCQuality**: the generator ships here next to the skills and knowledge it derives from, and the consuming orchestrator neither builds nor knows about the index. From 4f0a13a8013cd9264e46bfc09c0f3cbd90f09f09 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 3 Sep 2026 15:06:01 +0200 Subject: [PATCH 63/86] Promote knowledge for Microsoft review skills (#153) Move canonical knowledge for Microsoft-owned review domains into the Microsoft layer and document the skill/knowledge co-location policy. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Jesper Schulz-Wedde Copilot-Session: 2a6ea875-d38e-4f30-aadb-0d606f9be231 --- README.md | 8 +++++--- agent-consumption.md | 4 ++-- ...owning-table-must-delete-dependents-in-ondelete.bad.al | 0 ...wning-table-must-delete-dependents-in-ondelete.good.al | 0 .../owning-table-must-delete-dependents-in-ondelete.md | 0 ...transferfields-skip-type-mismatch-can-drop-data.bad.al | 0 ...ransferfields-skip-type-mismatch-can-drop-data.good.al | 0 .../transferfields-skip-type-mismatch-can-drop-data.md | 0 ...le-relation-false-suppresses-rename-propagation.bad.al | 0 ...e-relation-false-suppresses-rename-propagation.good.al | 0 ...-table-relation-false-suppresses-rename-propagation.md | 0 .../xrec-is-a-before-image-only-in-some-triggers.bad.al | 0 .../xrec-is-a-before-image-only-in-some-triggers.good.al | 0 .../xrec-is-a-before-image-only-in-some-triggers.md | 0 .../declare-event-publishers-local-or-internal.bad.al | 0 .../declare-event-publishers-local-or-internal.good.al | 0 .../events/declare-event-publishers-local-or-internal.md | 0 .../expose-process-context-via-manually-bound-flag.bad.al | 0 ...expose-process-context-via-manually-bound-flag.good.al | 0 .../expose-process-context-via-manually-bound-flag.md | 0 .../avoid-currpage-update-in-onaftergetrecord.bad.al | 0 .../avoid-currpage-update-in-onaftergetrecord.good.al | 0 .../avoid-currpage-update-in-onaftergetrecord.md | 0 ...atch-number-series-instead-of-getnextno-per-row.bad.al | 0 ...tch-number-series-instead-of-getnextno-per-row.good.al | 0 .../batch-number-series-instead-of-getnextno-per-row.md | 0 .../boolean-operators-do-not-short-circuit.bad.al | 0 .../boolean-operators-do-not-short-circuit.good.al | 0 .../performance/boolean-operators-do-not-short-circuit.md | 0 .../case-true-of-for-long-condition-chains.bad.al | 0 .../case-true-of-for-long-condition-chains.good.al | 0 .../performance/case-true-of-for-long-condition-chains.md | 0 .../performance/changecompany-in-loop-drops-caches.bad.al | 0 .../changecompany-in-loop-drops-caches.good.al | 0 .../performance/changecompany-in-loop-drops-caches.md | 0 ...dataaccessintent-readonly-on-analytical-objects.bad.al | 0 ...ataaccessintent-readonly-on-analytical-objects.good.al | 0 .../dataaccessintent-readonly-on-analytical-objects.md | 0 .../guiallowed-guard-on-pages-used-as-odata.bad.al | 0 .../guiallowed-guard-on-pages-used-as-odata.good.al | 0 .../guiallowed-guard-on-pages-used-as-odata.md | 0 ...httpclient-inside-write-transaction-holds-locks.bad.al | 0 ...ttpclient-inside-write-transaction-holds-locks.good.al | 0 .../httpclient-inside-write-transaction-holds-locks.md | 0 .../isempty-before-findset-is-extra-round-trip.bad.al | 0 .../isempty-before-findset-is-extra-round-trip.good.al | 0 .../isempty-before-findset-is-extra-round-trip.md | 0 .../oncompanyopen-subscribers-must-not-do-io.bad.al | 0 .../oncompanyopen-subscribers-must-not-do-io.good.al | 0 .../oncompanyopen-subscribers-must-not-do-io.md | 0 .../page-background-tasks-for-expensive-cues.bad.al | 0 .../page-background-tasks-for-expensive-cues.good.al | 0 .../page-background-tasks-for-expensive-cues.md | 0 ...-var-record-to-preserve-partial-load-enumerator.bad.al | 0 ...var-record-to-preserve-partial-load-enumerator.good.al | 0 ...pass-var-record-to-preserve-partial-load-enumerator.md | 0 ...fer-related-table-over-extension-on-hot-ledgers.bad.al | 0 ...er-related-table-over-extension-on-hot-ledgers.good.al | 0 .../prefer-related-table-over-extension-on-hot-ledgers.md | 0 .../query-results-bypass-primary-key-cache.bad.al | 0 .../query-results-bypass-primary-key-cache.good.al | 0 .../performance/query-results-bypass-primary-key-cache.md | 0 .../reset-clears-partial-record-selection.bad.al | 0 .../reset-clears-partial-record-selection.good.al | 0 .../performance/reset-clears-partial-record-selection.md | 0 .../skip-setloadfields-on-write-and-transferfields.bad.al | 0 ...skip-setloadfields-on-write-and-transferfields.good.al | 0 .../skip-setloadfields-on-write-and-transferfields.md | 0 .../use-dedicated-lookup-pages-not-full-lists.bad.al | 0 .../use-dedicated-lookup-pages-not-full-lists.good.al | 0 .../use-dedicated-lookup-pages-not-full-lists.md | 0 .../validate-on-partial-record-forces-jit.bad.al | 0 .../validate-on-partial-record-forces-jit.good.al | 0 .../performance/validate-on-partial-record-forces-jit.md | 0 .../guard-bulk-operations-with-istemporary.bad.al | 0 .../guard-bulk-operations-with-istemporary.good.al | 0 .../security/guard-bulk-operations-with-istemporary.md | 0 {community => microsoft}/knowledge/ui/factbox-design.md | 0 .../ui/showmandatory-on-code-required-page-fields.bad.al | 0 .../ui/showmandatory-on-code-required-page-fields.good.al | 0 .../ui/showmandatory-on-code-required-page-fields.md | 0 ...validate-request-page-input-in-onqueryclosepage.bad.al | 0 ...alidate-request-page-input-in-onqueryclosepage.good.al | 0 .../ui/validate-request-page-input-in-onqueryclosepage.md | 0 ...-enum-values-are-a-contract-by-name-not-ordinal.bad.al | 0 ...enum-values-are-a-contract-by-name-not-ordinal.good.al | 0 .../api-enum-values-are-a-contract-by-name-not-ordinal.md | 0 skills/write.md | 6 ++++-- 88 files changed, 11 insertions(+), 7 deletions(-) rename {community => microsoft}/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.bad.al (100%) rename {community => microsoft}/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.good.al (100%) rename {community => microsoft}/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md (100%) rename {community => microsoft}/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.bad.al (100%) rename {community => microsoft}/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.good.al (100%) rename {community => microsoft}/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md (100%) rename {community => microsoft}/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.bad.al (100%) rename {community => microsoft}/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.good.al (100%) rename {community => microsoft}/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md (100%) rename {community => microsoft}/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.bad.al (100%) rename {community => microsoft}/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.good.al (100%) rename {community => microsoft}/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md (100%) rename {community => microsoft}/knowledge/events/declare-event-publishers-local-or-internal.bad.al (100%) rename {community => microsoft}/knowledge/events/declare-event-publishers-local-or-internal.good.al (100%) rename {community => microsoft}/knowledge/events/declare-event-publishers-local-or-internal.md (100%) rename {community => microsoft}/knowledge/events/expose-process-context-via-manually-bound-flag.bad.al (100%) rename {community => microsoft}/knowledge/events/expose-process-context-via-manually-bound-flag.good.al (100%) rename {community => microsoft}/knowledge/events/expose-process-context-via-manually-bound-flag.md (100%) rename {community => microsoft}/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.bad.al (100%) rename {community => microsoft}/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.good.al (100%) rename {community => microsoft}/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md (100%) rename {community => microsoft}/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.bad.al (100%) rename {community => microsoft}/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.good.al (100%) rename {community => microsoft}/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md (100%) rename {community => microsoft}/knowledge/performance/boolean-operators-do-not-short-circuit.bad.al (100%) rename {community => microsoft}/knowledge/performance/boolean-operators-do-not-short-circuit.good.al (100%) rename {community => microsoft}/knowledge/performance/boolean-operators-do-not-short-circuit.md (100%) rename {community => microsoft}/knowledge/performance/case-true-of-for-long-condition-chains.bad.al (100%) rename {community => microsoft}/knowledge/performance/case-true-of-for-long-condition-chains.good.al (100%) rename {community => microsoft}/knowledge/performance/case-true-of-for-long-condition-chains.md (100%) rename {community => microsoft}/knowledge/performance/changecompany-in-loop-drops-caches.bad.al (100%) rename {community => microsoft}/knowledge/performance/changecompany-in-loop-drops-caches.good.al (100%) rename {community => microsoft}/knowledge/performance/changecompany-in-loop-drops-caches.md (100%) rename {community => microsoft}/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.bad.al (100%) rename {community => microsoft}/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.good.al (100%) rename {community => microsoft}/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md (100%) rename {community => microsoft}/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.bad.al (100%) rename {community => microsoft}/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.good.al (100%) rename {community => microsoft}/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md (100%) rename {community => microsoft}/knowledge/performance/httpclient-inside-write-transaction-holds-locks.bad.al (100%) rename {community => microsoft}/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al (100%) rename {community => microsoft}/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md (100%) rename {community => microsoft}/knowledge/performance/isempty-before-findset-is-extra-round-trip.bad.al (100%) rename {community => microsoft}/knowledge/performance/isempty-before-findset-is-extra-round-trip.good.al (100%) rename {community => microsoft}/knowledge/performance/isempty-before-findset-is-extra-round-trip.md (100%) rename {community => microsoft}/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.bad.al (100%) rename {community => microsoft}/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al (100%) rename {community => microsoft}/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md (100%) rename {community => microsoft}/knowledge/performance/page-background-tasks-for-expensive-cues.bad.al (100%) rename {community => microsoft}/knowledge/performance/page-background-tasks-for-expensive-cues.good.al (100%) rename {community => microsoft}/knowledge/performance/page-background-tasks-for-expensive-cues.md (100%) rename {community => microsoft}/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.bad.al (100%) rename {community => microsoft}/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.good.al (100%) rename {community => microsoft}/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md (100%) rename {community => microsoft}/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.bad.al (100%) rename {community => microsoft}/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.good.al (100%) rename {community => microsoft}/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md (100%) rename {community => microsoft}/knowledge/performance/query-results-bypass-primary-key-cache.bad.al (100%) rename {community => microsoft}/knowledge/performance/query-results-bypass-primary-key-cache.good.al (100%) rename {community => microsoft}/knowledge/performance/query-results-bypass-primary-key-cache.md (100%) rename {community => microsoft}/knowledge/performance/reset-clears-partial-record-selection.bad.al (100%) rename {community => microsoft}/knowledge/performance/reset-clears-partial-record-selection.good.al (100%) rename {community => microsoft}/knowledge/performance/reset-clears-partial-record-selection.md (100%) rename {community => microsoft}/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al (100%) rename {community => microsoft}/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al (100%) rename {community => microsoft}/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md (100%) rename {community => microsoft}/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al (100%) rename {community => microsoft}/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.good.al (100%) rename {community => microsoft}/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md (100%) rename {community => microsoft}/knowledge/performance/validate-on-partial-record-forces-jit.bad.al (100%) rename {community => microsoft}/knowledge/performance/validate-on-partial-record-forces-jit.good.al (100%) rename {community => microsoft}/knowledge/performance/validate-on-partial-record-forces-jit.md (100%) rename {community => microsoft}/knowledge/security/guard-bulk-operations-with-istemporary.bad.al (100%) rename {community => microsoft}/knowledge/security/guard-bulk-operations-with-istemporary.good.al (100%) rename {community => microsoft}/knowledge/security/guard-bulk-operations-with-istemporary.md (100%) rename {community => microsoft}/knowledge/ui/factbox-design.md (100%) rename {community => microsoft}/knowledge/ui/showmandatory-on-code-required-page-fields.bad.al (100%) rename {community => microsoft}/knowledge/ui/showmandatory-on-code-required-page-fields.good.al (100%) rename {community => microsoft}/knowledge/ui/showmandatory-on-code-required-page-fields.md (100%) rename {community => microsoft}/knowledge/ui/validate-request-page-input-in-onqueryclosepage.bad.al (100%) rename {community => microsoft}/knowledge/ui/validate-request-page-input-in-onqueryclosepage.good.al (100%) rename {community => microsoft}/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md (100%) rename {community => microsoft}/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.bad.al (100%) rename {community => microsoft}/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.good.al (100%) rename {community => microsoft}/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md (100%) diff --git a/README.md b/README.md index 7933725..43032d4 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ BCQuality contains **knowledge** and **skills**. It does not contain agents. Age ### Knowledge files -Atomic markdown files with YAML frontmatter. Each file covers one concern — one thing an agent would cite when reviewing or generating code. Knowledge files live in two layers: +Atomic markdown files with YAML frontmatter. Each file covers one concern — one thing an agent would cite when reviewing or generating code. Knowledge files live in three layers: - **`/microsoft/`** — Microsoft-endorsed layer. - `/microsoft/knowledge/` — Platform guardrails, official guidance. @@ -39,7 +39,9 @@ Atomic markdown files with YAML frontmatter. Each file covers one concern — on - `/custom/knowledge/` — Organization-specific knowledge files. - `/custom/skills/` — Organization-specific action skills. -All three layers are enabled by default when an agent consumes BCQuality. Content can be promoted from Community to Microsoft-endorsed once it proves itself — this is a first-class concept, not an afterthought. +All three layers are enabled by default when an agent consumes BCQuality. In the shared upstream layers, an action skill and the canonical knowledge it owns should live together: knowledge used by a Microsoft-endorsed skill belongs in `/microsoft/`, while `/community/` holds community-owned skills and their related knowledge. A split is acceptable briefly while a skill or corpus is being promoted, but it should not be the steady state. The `/custom/` layer remains the intentional exception because it overrides shared content in consumer forks. + +Layer authority follows review and ownership, not the contributor's affiliation. Community contributions to a Microsoft-owned knowledge domain can therefore be accepted directly into `/microsoft/`; content can also be promoted from Community to Microsoft-endorsed once its owning skill is promoted. ### Skills @@ -194,7 +196,7 @@ Contributions are welcome. Before submitting a PR: 1. Read the knowledge file format above — frontmatter and sections are validated by CI. 2. Keep files atomic: one concern per file, under 100 lines. -3. Target your contribution to the right layer — most community contributions go in `/community/knowledge/`. +3. Target your contribution to the layer that owns the action skill: use `/microsoft/knowledge/` for Microsoft-owned domains and `/community/knowledge/` for knowledge that accompanies a community-owned skill. 4. Adding a BC fact — or stopping the agent from flagging a false positive — is a knowledge file, not a skill edit. If a PR changes *what* a review skill flags, the change almost certainly belongs in a knowledge file. See [`skills/write.md`](skills/write.md). CI runs validation on every PR. If your knowledge file has schema violations, missing sections, code blocks, or exceeds 100 lines, the check will fail with a clear error message. diff --git a/agent-consumption.md b/agent-consumption.md index 61eb3c9..37a7a10 100644 --- a/agent-consumption.md +++ b/agent-consumption.md @@ -58,7 +58,7 @@ Each action skill is a markdown file that specifies what to do at each step. The | **Worklist** | Narrow from N candidates to the M that apply to this specific task. | | **Action** | Apply the relevant knowledge and produce structured output. | -Example: a performance review skill sources from `/microsoft/knowledge/performance/` and `/community/knowledge/performance/`, filters to `bc-version: 26` and `technologies: [al]`, narrows the 25 candidate files to the 8 that apply to the 15 objects changed in the PR, and then evaluates each file against the diff. +Example: the Microsoft-owned performance review skill selects `performance` entries across every enabled layer, filters to `bc-version: 26` and `technologies: [al]`, narrows the candidate files to those that apply to the changed objects, and then evaluates each file against the diff. Its canonical corpus lives beside it under `/microsoft/knowledge/performance/`; cross-layer entries are limited to custom overrides or short-lived promotion work. At this point the agent reads READ and DO on demand — it needs READ to interpret each knowledge file's frontmatter and sections, and DO to shape its output. Those contracts are fetched when first needed, not as part of bootstrap. @@ -104,7 +104,7 @@ Orchestrators MUST tolerate an absent `domain` in reports from older producers. into Entry's task context. Entry and the dispatched action skills remain authoritative. - **Layers decide authority, not code.** The agent sees `/microsoft/` and `/community/` together; if two files conflict, the precedence rule defined in READ resolves it. A partner fork can disable `/community/` — that's a config choice, not a code change. -- **Knowledge and skills evolve independently.** A new knowledge file requires no skill changes — existing skills pick it up via frontmatter filters. A new skill requires no knowledge changes — it sources from what's already there. +- **Knowledge and skills evolve independently within their owning layer.** A new knowledge file requires no skill changes because existing skills pick it up via frontmatter filters. Layer placement still follows skill ownership, so promoting a skill also promotes its canonical corpus. ## The mental model, in one sentence diff --git a/community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.bad.al b/microsoft/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.bad.al similarity index 100% rename from community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.bad.al rename to microsoft/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.bad.al diff --git a/community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.good.al b/microsoft/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.good.al similarity index 100% rename from community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.good.al rename to microsoft/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.good.al diff --git a/community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md b/microsoft/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md similarity index 100% rename from community/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md rename to microsoft/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md diff --git a/community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.bad.al b/microsoft/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.bad.al similarity index 100% rename from community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.bad.al rename to microsoft/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.bad.al diff --git a/community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.good.al b/microsoft/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.good.al similarity index 100% rename from community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.good.al rename to microsoft/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.good.al diff --git a/community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md b/microsoft/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md similarity index 100% rename from community/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md rename to microsoft/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md diff --git a/community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.bad.al b/microsoft/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.bad.al similarity index 100% rename from community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.bad.al rename to microsoft/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.bad.al diff --git a/community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.good.al b/microsoft/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.good.al similarity index 100% rename from community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.good.al rename to microsoft/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.good.al diff --git a/community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md b/microsoft/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md similarity index 100% rename from community/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md rename to microsoft/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md diff --git a/community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.bad.al b/microsoft/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.bad.al similarity index 100% rename from community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.bad.al rename to microsoft/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.bad.al diff --git a/community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.good.al b/microsoft/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.good.al similarity index 100% rename from community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.good.al rename to microsoft/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.good.al diff --git a/community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md b/microsoft/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md similarity index 100% rename from community/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md rename to microsoft/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md diff --git a/community/knowledge/events/declare-event-publishers-local-or-internal.bad.al b/microsoft/knowledge/events/declare-event-publishers-local-or-internal.bad.al similarity index 100% rename from community/knowledge/events/declare-event-publishers-local-or-internal.bad.al rename to microsoft/knowledge/events/declare-event-publishers-local-or-internal.bad.al diff --git a/community/knowledge/events/declare-event-publishers-local-or-internal.good.al b/microsoft/knowledge/events/declare-event-publishers-local-or-internal.good.al similarity index 100% rename from community/knowledge/events/declare-event-publishers-local-or-internal.good.al rename to microsoft/knowledge/events/declare-event-publishers-local-or-internal.good.al diff --git a/community/knowledge/events/declare-event-publishers-local-or-internal.md b/microsoft/knowledge/events/declare-event-publishers-local-or-internal.md similarity index 100% rename from community/knowledge/events/declare-event-publishers-local-or-internal.md rename to microsoft/knowledge/events/declare-event-publishers-local-or-internal.md diff --git a/community/knowledge/events/expose-process-context-via-manually-bound-flag.bad.al b/microsoft/knowledge/events/expose-process-context-via-manually-bound-flag.bad.al similarity index 100% rename from community/knowledge/events/expose-process-context-via-manually-bound-flag.bad.al rename to microsoft/knowledge/events/expose-process-context-via-manually-bound-flag.bad.al diff --git a/community/knowledge/events/expose-process-context-via-manually-bound-flag.good.al b/microsoft/knowledge/events/expose-process-context-via-manually-bound-flag.good.al similarity index 100% rename from community/knowledge/events/expose-process-context-via-manually-bound-flag.good.al rename to microsoft/knowledge/events/expose-process-context-via-manually-bound-flag.good.al diff --git a/community/knowledge/events/expose-process-context-via-manually-bound-flag.md b/microsoft/knowledge/events/expose-process-context-via-manually-bound-flag.md similarity index 100% rename from community/knowledge/events/expose-process-context-via-manually-bound-flag.md rename to microsoft/knowledge/events/expose-process-context-via-manually-bound-flag.md diff --git a/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.bad.al b/microsoft/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.bad.al similarity index 100% rename from community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.bad.al rename to microsoft/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.bad.al diff --git a/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.good.al b/microsoft/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.good.al similarity index 100% rename from community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.good.al rename to microsoft/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.good.al diff --git a/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md b/microsoft/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md similarity index 100% rename from community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md rename to microsoft/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md diff --git a/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.bad.al b/microsoft/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.bad.al similarity index 100% rename from community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.bad.al rename to microsoft/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.bad.al diff --git a/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.good.al b/microsoft/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.good.al similarity index 100% rename from community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.good.al rename to microsoft/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.good.al diff --git a/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md b/microsoft/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md similarity index 100% rename from community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md rename to microsoft/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md diff --git a/community/knowledge/performance/boolean-operators-do-not-short-circuit.bad.al b/microsoft/knowledge/performance/boolean-operators-do-not-short-circuit.bad.al similarity index 100% rename from community/knowledge/performance/boolean-operators-do-not-short-circuit.bad.al rename to microsoft/knowledge/performance/boolean-operators-do-not-short-circuit.bad.al diff --git a/community/knowledge/performance/boolean-operators-do-not-short-circuit.good.al b/microsoft/knowledge/performance/boolean-operators-do-not-short-circuit.good.al similarity index 100% rename from community/knowledge/performance/boolean-operators-do-not-short-circuit.good.al rename to microsoft/knowledge/performance/boolean-operators-do-not-short-circuit.good.al diff --git a/community/knowledge/performance/boolean-operators-do-not-short-circuit.md b/microsoft/knowledge/performance/boolean-operators-do-not-short-circuit.md similarity index 100% rename from community/knowledge/performance/boolean-operators-do-not-short-circuit.md rename to microsoft/knowledge/performance/boolean-operators-do-not-short-circuit.md diff --git a/community/knowledge/performance/case-true-of-for-long-condition-chains.bad.al b/microsoft/knowledge/performance/case-true-of-for-long-condition-chains.bad.al similarity index 100% rename from community/knowledge/performance/case-true-of-for-long-condition-chains.bad.al rename to microsoft/knowledge/performance/case-true-of-for-long-condition-chains.bad.al diff --git a/community/knowledge/performance/case-true-of-for-long-condition-chains.good.al b/microsoft/knowledge/performance/case-true-of-for-long-condition-chains.good.al similarity index 100% rename from community/knowledge/performance/case-true-of-for-long-condition-chains.good.al rename to microsoft/knowledge/performance/case-true-of-for-long-condition-chains.good.al diff --git a/community/knowledge/performance/case-true-of-for-long-condition-chains.md b/microsoft/knowledge/performance/case-true-of-for-long-condition-chains.md similarity index 100% rename from community/knowledge/performance/case-true-of-for-long-condition-chains.md rename to microsoft/knowledge/performance/case-true-of-for-long-condition-chains.md diff --git a/community/knowledge/performance/changecompany-in-loop-drops-caches.bad.al b/microsoft/knowledge/performance/changecompany-in-loop-drops-caches.bad.al similarity index 100% rename from community/knowledge/performance/changecompany-in-loop-drops-caches.bad.al rename to microsoft/knowledge/performance/changecompany-in-loop-drops-caches.bad.al diff --git a/community/knowledge/performance/changecompany-in-loop-drops-caches.good.al b/microsoft/knowledge/performance/changecompany-in-loop-drops-caches.good.al similarity index 100% rename from community/knowledge/performance/changecompany-in-loop-drops-caches.good.al rename to microsoft/knowledge/performance/changecompany-in-loop-drops-caches.good.al diff --git a/community/knowledge/performance/changecompany-in-loop-drops-caches.md b/microsoft/knowledge/performance/changecompany-in-loop-drops-caches.md similarity index 100% rename from community/knowledge/performance/changecompany-in-loop-drops-caches.md rename to microsoft/knowledge/performance/changecompany-in-loop-drops-caches.md diff --git a/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.bad.al b/microsoft/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.bad.al similarity index 100% rename from community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.bad.al rename to microsoft/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.bad.al diff --git a/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.good.al b/microsoft/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.good.al similarity index 100% rename from community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.good.al rename to microsoft/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.good.al diff --git a/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md b/microsoft/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md similarity index 100% rename from community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md rename to microsoft/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md diff --git a/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.bad.al b/microsoft/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.bad.al similarity index 100% rename from community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.bad.al rename to microsoft/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.bad.al diff --git a/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.good.al b/microsoft/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.good.al similarity index 100% rename from community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.good.al rename to microsoft/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.good.al diff --git a/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md b/microsoft/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md similarity index 100% rename from community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md rename to microsoft/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md diff --git a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.bad.al b/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.bad.al similarity index 100% rename from community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.bad.al rename to microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.bad.al diff --git a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al b/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al similarity index 100% rename from community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al rename to microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al diff --git a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md b/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md similarity index 100% rename from community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md rename to microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md diff --git a/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.bad.al b/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.bad.al similarity index 100% rename from community/knowledge/performance/isempty-before-findset-is-extra-round-trip.bad.al rename to microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.bad.al diff --git a/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.good.al b/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.good.al similarity index 100% rename from community/knowledge/performance/isempty-before-findset-is-extra-round-trip.good.al rename to microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.good.al diff --git a/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.md b/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.md similarity index 100% rename from community/knowledge/performance/isempty-before-findset-is-extra-round-trip.md rename to microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.md diff --git a/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.bad.al b/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.bad.al similarity index 100% rename from community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.bad.al rename to microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.bad.al diff --git a/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al b/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al similarity index 100% rename from community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al rename to microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al diff --git a/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md b/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md similarity index 100% rename from community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md rename to microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md diff --git a/community/knowledge/performance/page-background-tasks-for-expensive-cues.bad.al b/microsoft/knowledge/performance/page-background-tasks-for-expensive-cues.bad.al similarity index 100% rename from community/knowledge/performance/page-background-tasks-for-expensive-cues.bad.al rename to microsoft/knowledge/performance/page-background-tasks-for-expensive-cues.bad.al diff --git a/community/knowledge/performance/page-background-tasks-for-expensive-cues.good.al b/microsoft/knowledge/performance/page-background-tasks-for-expensive-cues.good.al similarity index 100% rename from community/knowledge/performance/page-background-tasks-for-expensive-cues.good.al rename to microsoft/knowledge/performance/page-background-tasks-for-expensive-cues.good.al diff --git a/community/knowledge/performance/page-background-tasks-for-expensive-cues.md b/microsoft/knowledge/performance/page-background-tasks-for-expensive-cues.md similarity index 100% rename from community/knowledge/performance/page-background-tasks-for-expensive-cues.md rename to microsoft/knowledge/performance/page-background-tasks-for-expensive-cues.md diff --git a/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.bad.al b/microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.bad.al similarity index 100% rename from community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.bad.al rename to microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.bad.al diff --git a/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.good.al b/microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.good.al similarity index 100% rename from community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.good.al rename to microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.good.al diff --git a/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md b/microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md similarity index 100% rename from community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md rename to microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md diff --git a/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.bad.al b/microsoft/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.bad.al similarity index 100% rename from community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.bad.al rename to microsoft/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.bad.al diff --git a/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.good.al b/microsoft/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.good.al similarity index 100% rename from community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.good.al rename to microsoft/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.good.al diff --git a/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md b/microsoft/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md similarity index 100% rename from community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md rename to microsoft/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md diff --git a/community/knowledge/performance/query-results-bypass-primary-key-cache.bad.al b/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.bad.al similarity index 100% rename from community/knowledge/performance/query-results-bypass-primary-key-cache.bad.al rename to microsoft/knowledge/performance/query-results-bypass-primary-key-cache.bad.al diff --git a/community/knowledge/performance/query-results-bypass-primary-key-cache.good.al b/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.good.al similarity index 100% rename from community/knowledge/performance/query-results-bypass-primary-key-cache.good.al rename to microsoft/knowledge/performance/query-results-bypass-primary-key-cache.good.al diff --git a/community/knowledge/performance/query-results-bypass-primary-key-cache.md b/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.md similarity index 100% rename from community/knowledge/performance/query-results-bypass-primary-key-cache.md rename to microsoft/knowledge/performance/query-results-bypass-primary-key-cache.md diff --git a/community/knowledge/performance/reset-clears-partial-record-selection.bad.al b/microsoft/knowledge/performance/reset-clears-partial-record-selection.bad.al similarity index 100% rename from community/knowledge/performance/reset-clears-partial-record-selection.bad.al rename to microsoft/knowledge/performance/reset-clears-partial-record-selection.bad.al diff --git a/community/knowledge/performance/reset-clears-partial-record-selection.good.al b/microsoft/knowledge/performance/reset-clears-partial-record-selection.good.al similarity index 100% rename from community/knowledge/performance/reset-clears-partial-record-selection.good.al rename to microsoft/knowledge/performance/reset-clears-partial-record-selection.good.al diff --git a/community/knowledge/performance/reset-clears-partial-record-selection.md b/microsoft/knowledge/performance/reset-clears-partial-record-selection.md similarity index 100% rename from community/knowledge/performance/reset-clears-partial-record-selection.md rename to microsoft/knowledge/performance/reset-clears-partial-record-selection.md diff --git a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al b/microsoft/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al similarity index 100% rename from community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al rename to microsoft/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al diff --git a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al b/microsoft/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al similarity index 100% rename from community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al rename to microsoft/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al diff --git a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md b/microsoft/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md similarity index 100% rename from community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md rename to microsoft/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md diff --git a/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al b/microsoft/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al similarity index 100% rename from community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al rename to microsoft/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al diff --git a/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.good.al b/microsoft/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.good.al similarity index 100% rename from community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.good.al rename to microsoft/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.good.al diff --git a/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md b/microsoft/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md similarity index 100% rename from community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md rename to microsoft/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md diff --git a/community/knowledge/performance/validate-on-partial-record-forces-jit.bad.al b/microsoft/knowledge/performance/validate-on-partial-record-forces-jit.bad.al similarity index 100% rename from community/knowledge/performance/validate-on-partial-record-forces-jit.bad.al rename to microsoft/knowledge/performance/validate-on-partial-record-forces-jit.bad.al diff --git a/community/knowledge/performance/validate-on-partial-record-forces-jit.good.al b/microsoft/knowledge/performance/validate-on-partial-record-forces-jit.good.al similarity index 100% rename from community/knowledge/performance/validate-on-partial-record-forces-jit.good.al rename to microsoft/knowledge/performance/validate-on-partial-record-forces-jit.good.al diff --git a/community/knowledge/performance/validate-on-partial-record-forces-jit.md b/microsoft/knowledge/performance/validate-on-partial-record-forces-jit.md similarity index 100% rename from community/knowledge/performance/validate-on-partial-record-forces-jit.md rename to microsoft/knowledge/performance/validate-on-partial-record-forces-jit.md diff --git a/community/knowledge/security/guard-bulk-operations-with-istemporary.bad.al b/microsoft/knowledge/security/guard-bulk-operations-with-istemporary.bad.al similarity index 100% rename from community/knowledge/security/guard-bulk-operations-with-istemporary.bad.al rename to microsoft/knowledge/security/guard-bulk-operations-with-istemporary.bad.al diff --git a/community/knowledge/security/guard-bulk-operations-with-istemporary.good.al b/microsoft/knowledge/security/guard-bulk-operations-with-istemporary.good.al similarity index 100% rename from community/knowledge/security/guard-bulk-operations-with-istemporary.good.al rename to microsoft/knowledge/security/guard-bulk-operations-with-istemporary.good.al diff --git a/community/knowledge/security/guard-bulk-operations-with-istemporary.md b/microsoft/knowledge/security/guard-bulk-operations-with-istemporary.md similarity index 100% rename from community/knowledge/security/guard-bulk-operations-with-istemporary.md rename to microsoft/knowledge/security/guard-bulk-operations-with-istemporary.md diff --git a/community/knowledge/ui/factbox-design.md b/microsoft/knowledge/ui/factbox-design.md similarity index 100% rename from community/knowledge/ui/factbox-design.md rename to microsoft/knowledge/ui/factbox-design.md diff --git a/community/knowledge/ui/showmandatory-on-code-required-page-fields.bad.al b/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.bad.al similarity index 100% rename from community/knowledge/ui/showmandatory-on-code-required-page-fields.bad.al rename to microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.bad.al diff --git a/community/knowledge/ui/showmandatory-on-code-required-page-fields.good.al b/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.good.al similarity index 100% rename from community/knowledge/ui/showmandatory-on-code-required-page-fields.good.al rename to microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.good.al diff --git a/community/knowledge/ui/showmandatory-on-code-required-page-fields.md b/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.md similarity index 100% rename from community/knowledge/ui/showmandatory-on-code-required-page-fields.md rename to microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.md diff --git a/community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.bad.al b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.bad.al similarity index 100% rename from community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.bad.al rename to microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.bad.al diff --git a/community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.good.al b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.good.al similarity index 100% rename from community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.good.al rename to microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.good.al diff --git a/community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md similarity index 100% rename from community/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md rename to microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md diff --git a/community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.bad.al b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.bad.al similarity index 100% rename from community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.bad.al rename to microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.bad.al diff --git a/community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.good.al b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.good.al similarity index 100% rename from community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.good.al rename to microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.good.al diff --git a/community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md similarity index 100% rename from community/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md rename to microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md diff --git a/skills/write.md b/skills/write.md index 731f469..8096f1a 100644 --- a/skills/write.md +++ b/skills/write.md @@ -80,8 +80,10 @@ Knowledge files do not contain code. Samples live as **sibling files** next to t ## Choosing a layer -- **`/microsoft/knowledge//`** — platform-endorsed guidance. Authored or approved by the BC platform team. Use this layer only when the guidance reflects a platform guarantee or official recommendation. -- **`/community/knowledge//`** — shared community patterns. The default layer for contributions from outside the platform team. Content here can be promoted to `/microsoft/` once it proves itself. +In the shared upstream layers, keep an action skill and the canonical knowledge it acts on in the same layer. The action skill's ownership determines the destination; the author's affiliation does not. Do not use `/community/knowledge/` as a staging area for articles in a domain already owned by a Microsoft-endorsed skill. A cross-layer split is acceptable only as a short-lived migration state while the skill or corpus is being promoted. Custom overrides are intentionally exempt because they extend shared skills from a consumer fork. + +- **`/microsoft/knowledge//`** — guidance owned by a Microsoft-endorsed action skill. It has been approved as platform-endorsed guidance, whether authored by Microsoft or contributed by the community. +- **`/community/knowledge//`** — knowledge that accompanies a community-owned action skill. Promote the knowledge with the skill when that skill becomes Microsoft-endorsed. - **`/custom/knowledge//`** — partner or customer overrides. Generally does not appear in the BCQuality repository itself; `/custom/` lives in consumer repositories. ### Writing to `/custom/` — fork precondition From 8584217c7506eea7eef27a9db353d78c0cd6a9a1 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Mon, 7 Sep 2026 15:13:35 +0200 Subject: [PATCH 64/86] Clarify page field caption and tooltip inheritance (#160) * Clarify page field caption and tooltip inheritance Prevent redundant page-level properties by documenting inherited captions and BC24/runtime 13.0 table-field tooltips. Correct companion examples and version-scoped tooltip guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address tooltip quality and knowledge scope review feedback Require useful, behavior-grounded tooltip text rather than caption repetition, improve the samples, and explain why compiler feedback does not prevent redundant page captions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../caption-required-on-page-fields.bad.al | 8 +++- .../caption-required-on-page-fields.good.al | 45 ++++++++++++++++-- .../style/caption-required-on-page-fields.md | 24 +++++++--- .../tooltip-required-on-page-fields.bad.al | 12 +++-- .../tooltip-required-on-page-fields.good.al | 46 +++++++++++++++++-- .../style/tooltip-required-on-page-fields.md | 30 ++++++++---- ...age-field-inherits-source-field-tooltip.md | 14 ++++-- 7 files changed, 147 insertions(+), 32 deletions(-) diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.bad.al b/microsoft/knowledge/style/caption-required-on-page-fields.bad.al index fd458a4..21dccb3 100644 --- a/microsoft/knowledge/style/caption-required-on-page-fields.bad.al +++ b/microsoft/knowledge/style/caption-required-on-page-fields.bad.al @@ -9,16 +9,22 @@ page 50253 "Sample Caption Bad" { group(General) { - field("Customer No."; Rec."No.") + Caption = 'General'; + field(CustomerNoValue; CustomerNoValue) { ApplicationArea = All; + ToolTip = 'Specifies the customer number to look up.'; } field("Customer Name"; Rec.Name) { ApplicationArea = All; Caption = ''; + ToolTip = 'Specifies the customer name shown on sales documents.'; } } } } + + var + CustomerNoValue: Code[20]; } diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.good.al b/microsoft/knowledge/style/caption-required-on-page-fields.good.al index 0493b6e..f91ed73 100644 --- a/microsoft/knowledge/style/caption-required-on-page-fields.good.al +++ b/microsoft/knowledge/style/caption-required-on-page-fields.good.al @@ -1,7 +1,36 @@ +// BC24 / runtime 13.0 or later for table-field tooltips. +table 50252 "Sample Caption Source" +{ + Caption = 'Caption Source'; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) + { + Caption = 'No.'; + ToolTip = 'Specifies the unique number used to distinguish this customer record from other records.'; + } + field(2; Name; Text[100]) + { + Caption = 'Name'; + ToolTip = 'Specifies the name used to identify the customer alongside the unique customer number.'; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + } +} + page 50252 "Sample Caption Good" { PageType = Card; - SourceTable = Customer; + SourceTable = "Sample Caption Source"; layout { @@ -10,19 +39,25 @@ page 50252 "Sample Caption Good" group(General) { Caption = 'General'; - field("Customer No."; Rec."No.") + field("No."; Rec."No.") { ApplicationArea = All; - Caption = 'Customer No.'; - ToolTip = 'Specifies the customer number.'; } field("Customer Name"; Rec.Name) { ApplicationArea = All; Caption = 'Customer Name'; - ToolTip = 'Specifies the customer name.'; + } + field(DisplayValue; DisplayValue) + { + ApplicationArea = All; + Caption = 'Display Value'; + ToolTip = 'Specifies temporary text for this page; the text is not saved in the customer record.'; } } } } + + var + DisplayValue: Text[100]; } diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.md b/microsoft/knowledge/style/caption-required-on-page-fields.md index e3a69c3..b5d2e03 100644 --- a/microsoft/knowledge/style/caption-required-on-page-fields.md +++ b/microsoft/knowledge/style/caption-required-on-page-fields.md @@ -1,28 +1,38 @@ --- bc-version: [all] domain: style -keywords: [caption, page-field, aa0225, aa0226, codecop, captionclass] +keywords: [caption, page-field, source-field, inheritance, aa0225, aa0226, codecop, captionclass, false-positive] technologies: [al] countries: [w1] application-area: [all] --- -# Every page field needs a `Caption` (CodeCop AA0225/AA0226) +# Page fields can inherit their source table field's `Caption` ## 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. +A page field bound to a table field inherits the source field's `Caption` unless the page overrides it. An inherited caption is valid, user-facing, and translatable; omitting a page-level `Caption` does not mean the control displays an internal identifier or loses translations. CodeCop AA0225/AA0226 concern missing or empty captions, not a requirement to duplicate a caption already supplied by the source table field. -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. +Redundant page-level captions compile successfully, so compiler-error recovery does not prevent an agent from adding them. This guidance prevents that false positive rather than replacing analyzer diagnostics. + +Controls bound to variables or expressions cannot rely on table-field caption inheritance. For user-facing fields that need a label, supply a `Caption` or a `CaptionClass` that resolves to the intended caption. API pages are not human-facing UI; do not apply this UI-label guidance to their API contract names. ## 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. +Define the shared caption on the table field and let bound page fields inherit it. Add a page-level `Caption` only when there is no suitable inherited caption or the page genuinely needs different wording. Keep a valid `CaptionClass` rather than adding a redundant literal caption. -See sample: `caption-required-on-page-fields.good.al`. +Before reporting a missing caption, inspect the binding and source field, including dependency symbols when needed. If the source definition is unavailable, do not treat an omitted page property as proof that the caption is missing. Caption and tooltip requirements are separate: do not add a `ToolTip` just because a caption is being reviewed; see [tooltip inheritance guidance](tooltip-required-on-page-fields.md). + +See sample: `caption-required-on-page-fields.good.al`. Caption inheritance applies across BC versions; the sample uses BC24/runtime 13.0 or later to also define tooltips on its table fields. ## 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. +A user-facing field that needs a label but has no non-empty explicit or inherited caption and no resolving `CaptionClass` has a genuine labeling gap. This includes `Caption = '';` when no `CaptionClass` supplies the label. A variable name alone is not a translatable caption. + +The opposite review defect is flagging a bound field solely because it omits a page-level `Caption`, or inserting a copy of the table field's caption to satisfy AA0225/AA0226. That adds redundant text and prevents subsequent table-caption changes from flowing through to the page. See sample: `caption-required-on-page-fields.bad.al`. + +## References + +[Caption property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-caption-property) and [ToolTip property remarks documenting inheritance of both properties](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-tooltip-property). diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al b/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al index e6b356c..6f5f269 100644 --- a/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al @@ -1,23 +1,29 @@ page 50251 "Sample Tooltip Bad" { PageType = Card; - SourceTable = Customer; layout { area(Content) { group(General) { - field("No."; Rec."No.") + Caption = 'General'; + field(CustomerNoValue; CustomerNoValue) { ApplicationArea = All; + Caption = 'Customer No.'; } - field(Amount; Rec."Balance (LCY)") + field(PreviewAmount; PreviewAmount) { ApplicationArea = All; + Caption = 'Preview Amount'; ToolTip = ''; } } } } + + var + CustomerNoValue: Code[20]; + PreviewAmount: Decimal; } diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al b/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al index 1816de5..cd1fc8c 100644 --- a/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al @@ -1,24 +1,62 @@ +// BC24 / runtime 13.0 or later. +table 50250 "Sample Tooltip Source" +{ + Caption = 'Tooltip Source'; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) + { + Caption = 'No.'; + ToolTip = 'Specifies the unique number used to distinguish this entry from other entries.'; + } + field(2; Amount; Decimal) + { + Caption = 'Amount'; + ToolTip = 'Specifies the monetary value recorded for this entry; changing it updates the saved entry.'; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + } +} + page 50250 "Sample Tooltip Good" { PageType = Card; - SourceTable = Customer; + SourceTable = "Sample Tooltip Source"; layout { area(Content) { group(General) { + Caption = 'General'; field("No."; Rec."No.") { ApplicationArea = All; - ToolTip = 'Specifies the number that identifies the customer.'; } - field(Amount; Rec."Balance (LCY)") + field(Amount; Rec.Amount) { ApplicationArea = All; - ToolTip = 'Shows the total balance in local currency.'; + ToolTip = 'Specifies the recorded amount to compare with the temporary preview amount.'; + } + field(PreviewAmount; PreviewAmount) + { + ApplicationArea = All; + Caption = 'Preview Amount'; + ToolTip = 'Specifies a temporary amount to compare with the recorded entry amount; this value is not saved.'; } } } } + + var + PreviewAmount: Decimal; } diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.md b/microsoft/knowledge/style/tooltip-required-on-page-fields.md index a11d4a9..bd1dd2b 100644 --- a/microsoft/knowledge/style/tooltip-required-on-page-fields.md +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.md @@ -1,30 +1,44 @@ --- bc-version: [all] domain: style -keywords: [tooltip, page-field, aa0218, codecop, accessibility, specifies] +keywords: [tooltip, page-field, source-field, inheritance, aa0218, codecop, accessibility, specifies] technologies: [al] countries: [w1] application-area: [all] --- -# Every page field needs a `ToolTip` (CodeCop AA0218) +# Page fields need an explicit or inherited `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. +User-facing page fields need tooltip text, but it does not have to be declared on each page control. Starting with BC24 (2024 release wave 1), runtime 13.0 supports `ToolTip` on table fields, and bound page fields inherit it unless they override it. A non-empty inherited tooltip satisfies the requirement; do not interpret CodeCop AA0218 as a requirement to repeat it on the page. -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. +For targets before runtime 13.0, table-field tooltip inheritance is not available, so user-facing page fields need page-level tooltips. Controls bound to variables or expressions also need page-level tooltips because they have no table field to inherit from. This is UI guidance, not a blanket requirement to add tooltips to every table field, including fields never exposed to users. -AA0218 is a compiler analyzer, but its severity is configured per app in the ruleset and is frequently downgraded to `info`/`None` or disabled entirely. PR review therefore cannot assume the compiler will surface the gap: it is the last line of defence for a missing tooltip and should flag it independently. The one case review must *not* flag is a bound field that inherits a `ToolTip` from its source table field — see `bound-page-field-inherits-source-field-tooltip`. +AA0218's severity is configured per app and may be downgraded or disabled. Review should still report a genuinely missing tooltip, but absence of a page-level declaration alone is not evidence of a gap. See [bound page-field tooltip inheritance](../ui/bound-page-field-inherits-source-field-tooltip.md). ## 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". In review, raise a `medium`-severity finding for a field that has neither an inline nor an inherited tooltip, independently of whether AA0218 is active in the app's ruleset. +On runtime 13.0 or later, define shared tooltip text on the table field and omit duplicate page-level properties. Add a page-level `ToolTip` when no tooltip can be inherited or when the page needs different, context-specific help. Describe what the value shows, conventionally starting with "Specifies" or another clear phrasing. -See sample: `tooltip-required-on-page-fields.good.al`. +Make the text answer a question the caption does not: what the value is used for, which values or units are expected, or what changing it affects. Do not mechanically generate "Specifies the ." and consider the help complete. Use behavior established by the implementation or requirements; do not invent effects, defaults, or constraints to make a tooltip sound useful. Keep shared table-field help applicable to all pages that inherit it, and improve that shared text rather than duplicating it on each page. + +Before raising a `medium`-severity finding, check the target runtime, the control's binding, and the source field's tooltip, including dependency symbols when needed. Report a field with neither an explicit nor an inherited tooltip independently of whether AA0218 is active. If the source definition or target runtime is unavailable, do not assume a missing page property means missing tooltip text. + +See sample: `tooltip-required-on-page-fields.good.al` (BC24/runtime 13.0 or later). ## 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. +A user-facing control with no page-level `ToolTip` and no non-empty source tooltip it can inherit, or a page-level `ToolTip = '';` that leaves the effective tooltip empty. + +Flagging a bound field that already inherits its tooltip, or adding the same tooltip to every page, is also incorrect: duplicate overrides add maintenance and translation work and prevent source-field tooltip changes from reaching those pages. + +Treating a non-empty tooltip that merely repeats the caption as useful help is a separate quality issue, not a missing-tooltip finding. Point out the concrete information users need rather than demanding longer wording or a page-level override for its own sake. See sample: `tooltip-required-on-page-fields.bad.al`. + +## References + +[ToolTip property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-tooltip-property). + +[Guidelines for tooltip text](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/user-assistance#guidelines-for-tooltip-text). diff --git a/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md b/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md index 87b539b..00dc9b8 100644 --- a/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md +++ b/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [24..] domain: ui keywords: [tooltip, page-field, source-field, inheritance, aa0218, false-positive] technologies: [al] @@ -11,14 +11,20 @@ application-area: [all] ## Description -A page field bound to a table field inherits the source field's `ToolTip` at runtime: the control shows the table field's `ToolTip` even when the page control declares none of its own. A page field without an inline `ToolTip` is therefore not, by itself, a missing-tooltip defect — the text may be supplied by the bound source field. +Starting with BC24 (2024 release wave 1), runtime 13.0 supports `ToolTip` on table fields. A page field bound to a table field inherits the source field's `ToolTip` when the page control declares none of its own. A page field without an inline `ToolTip` is therefore not, by itself, a missing-tooltip defect. This inheritance is not available when targeting earlier runtimes. -The genuinely-missing case is different: a bound field whose source table field *also* carries no `ToolTip`, or an unbound control, has no text to inherit and is a real accessibility gap. The compiler analyzer AA0218 detects this mechanically, but its severity is set by each app's ruleset and is routinely downgraded or disabled — so it cannot be relied on as the only net. PR review is the last line of defence and should raise this case independently. +The genuinely-missing case is different: a control with no inline `ToolTip` also has no text to inherit when it is unbound or its source table field carries no non-empty `ToolTip`. This leaves a real user-assistance gap. The compiler analyzer AA0218 detects this mechanically, but its severity is set by each app's ruleset and may be downgraded or disabled, so review should raise the genuine gap independently. ## Best Practice -Do not raise a missing-`ToolTip` finding for a bound page field whose source table field supplies a `ToolTip`; assume the control inherits it. Do raise a `medium`-severity finding when the field has no inline `ToolTip` **and** no inherited one — that is, a bound field whose source field is also tooltip-less, or an unbound control — rather than assuming AA0218 will catch it downstream. +Check the target runtime and inspect the source field, including dependency symbols when needed. On runtime 13.0 or later, do not raise a missing-`ToolTip` finding for a bound page field whose source table field supplies a non-empty `ToolTip`, and do not add a duplicate page-level property. A page-level override is appropriate only when the page needs different help text or no tooltip can be inherited. + +Do raise a `medium`-severity finding when the field has no inline `ToolTip` **and** no inherited one, rather than assuming AA0218 will catch it downstream. If the source definition is unavailable, do not infer that its tooltip is missing. See [tooltip requirements across target versions](../style/tooltip-required-on-page-fields.md). ## Anti Pattern Two opposite failures: (1) flagging every page field that has no inline `ToolTip` as a violation, ignoring that a bound field inherits its source field's tooltip; and (2) staying silent on a field that has neither an inline nor an inherited tooltip on the assumption that the compiler's AA0218 will report it — a ruleset that downgrades or disables AA0218 then lets a genuine gap ship unflagged. + +## References + +[ToolTip property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-tooltip-property). From 17bb84a25e23e8384eecb8d89e22b16957079bd4 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Wed, 9 Sep 2026 16:55:35 +0200 Subject: [PATCH 65/86] Support standalone runners and complete app-folder reviews (#172) * Document standalone review runner contract Keep model selection and scheduling outside BCQuality while allowing orchestrators to run isolated review leaves concurrently with deterministic rollup semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Support complete app folder reviews Define folder-path as a current-state review scope and accept it across the standalone adapter, broad coordinator, and every AL review leaf. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add walk-up app review quick start Put the complete app-folder installation and prompt flow directly in the README so partners can discover the standalone experience without reading integration details first. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Organize conceptual guides under docs Move architecture and standalone runner documentation out of the repository root, add a documentation index, and update all inbound links. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 34 +++++- docs/README.md | 8 ++ .../agent-consumption.md | 3 +- docs/standalone-runner.md | 103 ++++++++++++++++++ .../skills/review/al-appsource-review.md | 4 +- .../review/al-breaking-changes-review.md | 4 +- microsoft/skills/review/al-code-review.md | 14 +-- .../skills/review/al-data-modeling-review.md | 4 +- .../skills/review/al-error-handling-review.md | 4 +- microsoft/skills/review/al-events-review.md | 4 +- .../skills/review/al-interfaces-review.md | 4 +- .../skills/review/al-performance-review.md | 4 +- microsoft/skills/review/al-privacy-review.md | 4 +- microsoft/skills/review/al-query-review.md | 2 +- microsoft/skills/review/al-security-review.md | 4 +- microsoft/skills/review/al-style-review.md | 4 +- .../skills/review/al-telemetry-review.md | 4 +- microsoft/skills/review/al-testing-review.md | 4 +- microsoft/skills/review/al-ui-review.md | 4 +- microsoft/skills/review/al-upgrade-review.md | 4 +- .../skills/review/al-web-services-review.md | 4 +- skills/README.md | 2 +- skills/al-code-review/SKILL.md | 10 +- skills/do.md | 43 +++++++- skills/entry.md | 1 + 25 files changed, 229 insertions(+), 51 deletions(-) create mode 100644 docs/README.md rename agent-consumption.md => docs/agent-consumption.md (98%) create mode 100644 docs/standalone-runner.md diff --git a/README.md b/README.md index 43032d4..f3ea476 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,12 @@ Skills define how agents consume knowledge. They come in three flavors: ### Agent bootstrapping -An orchestrator (such as AL-Go) points the agent at BCQuality's URL and provides a task context. The agent's first call is `/skills/entry.md`, which returns a dispatch record naming the action skill(s) to invoke. The agent then invokes each dispatched skill in turn, reading READ and DO on demand. No prior knowledge of BCQuality's structure is baked into the orchestrator — only the convention *"invoke `/skills/entry.md` first."* +An orchestrator (such as AL-Go) points the agent at BCQuality's URL and provides a task context. The agent's first call is `/skills/entry.md`, which returns a dispatch record naming the action skill(s) to invoke. The agent then invokes the dispatched skills, reading READ and DO on demand. No prior knowledge of BCQuality's structure is baked into the orchestrator — only the convention *"invoke `/skills/entry.md` first."* ### Standalone plugin installation -BCQuality can also be installed directly as a plugin. The plugin registers one +BCQuality can also be installed directly as a plugin to review a complete AL +app folder, a change set, or an individual file. The plugin registers one host-native skill, [`al-code-review`](skills/al-code-review/SKILL.md), which adapts the caller's request to the same Entry protocol used by orchestrators. @@ -75,6 +76,24 @@ For GitHub Copilot CLI: copilot plugin install microsoft/BCQuality ``` +#### Review a complete app folder + +1. Open the Business Central app folder in GitHub Copilot and start a fresh + session after installing the plugin. +2. Ask: + + > Use the installed `al-code-review` skill to review the complete Business + > Central app in this folder. Execute every dispatched review domain and + > return the complete BCQuality findings report. + +That is the complete walk-up flow. The folder does not need to be a Git +repository; BCQuality reviews `app.json` and the AL source below it. To pick up +a newer BCQuality release later, run: + +```shell +copilot plugin update bcquality +``` + Plugin version `0.2.0` renamed the former `bcquality-al-review` skill to `al-code-review`; explicit invocations and allowlists using the old skill name must be updated. The name remains distinct from BC-ALAgents' public @@ -108,6 +127,12 @@ formats. Their paths make the boundary explicit. The adapter lives under `skills/al-code-review/SKILL.md`; the internal Microsoft-layer coordinator lives at `microsoft/skills/review/al-code-review.md`. +Partners that want model selection, parallel leaf execution, retries, or usage +telemetry can add a thin runner outside BCQuality. See +[Build a lightweight standalone review runner](docs/standalone-runner.md) for the +integration contract and a minimal implementation checklist. Architecture and +partner guides are collected in the [documentation index](docs/README.md). + ## Knowledge file format Every knowledge file is a markdown file with mandatory YAML frontmatter. Files target under 100 lines (ideal under 50). If two ideas would share a file, split them. @@ -153,16 +178,17 @@ 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. +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 [How agents consume BCQuality](docs/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). +For the end-to-end flow — from orchestrator trigger through to how output reaches developers — see [How agents consume BCQuality](docs/agent-consumption.md). ## Repository structure ``` ├── /skills/ # Global: entry-point skill + meta-skill contracts (READ, DO, WRITE) +├── /docs/ # Architecture and partner integration guides ├── /evaluation/ # Neutral good/bad review fixtures and scoring contract ├── /.github/ # Actions and workflows ├── /microsoft/ # Microsoft-endorsed layer diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c8e8290 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,8 @@ +# Documentation + +- [How agents consume BCQuality](agent-consumption.md) explains the operational + flow from Entry dispatch through structured findings and integration. +- [Build a lightweight standalone review runner](standalone-runner.md) explains + the walk-up app-folder flow and how an external runner can add model + selection, concurrency, retries, and telemetry without moving orchestration + into BCQuality. diff --git a/agent-consumption.md b/docs/agent-consumption.md similarity index 98% rename from agent-consumption.md rename to docs/agent-consumption.md index 37a7a10..aaa6772 100644 --- a/agent-consumption.md +++ b/docs/agent-consumption.md @@ -2,7 +2,8 @@ BCQuality is content — knowledge files and skills. It is consumed by agents that live elsewhere (AL-Go, a VS Code extension, a GitHub Agent invocation, etc.). This document explains the end-to-end flow, so that skill authors, orchestrator maintainers, and contributors share one mental model. -For the high-level framing and repo structure, start with the [README](README.md). This document is the operational view. +For the high-level framing and repo structure, start with the +[README](../README.md). This document is the operational view. ## The actors diff --git a/docs/standalone-runner.md b/docs/standalone-runner.md new file mode 100644 index 0000000..605ffcc --- /dev/null +++ b/docs/standalone-runner.md @@ -0,0 +1,103 @@ +# Build a lightweight standalone review runner + +BCQuality provides review knowledge, routing, execution instructions, and +structured output contracts. It intentionally does not choose models, schedule +agents, retry failures, or collect usage telemetry. A standalone runner can add +those host-specific capabilities without copying Business Central rules out of +BCQuality. + +Use the built-in standalone plugin when the host's default execution is +sufficient. Build a runner when you need explicit control over cost, latency, +concurrency, or integration with another review surface. + +## Keep BCQuality current + +Install or update the plugin with GitHub Copilot CLI: + +```shell +copilot plugin install microsoft/BCQuality +copilot plugin update bcquality +``` + +A runner that reads BCQuality from a checkout should pin a commit or release +and upgrade it deliberately. Do not copy knowledge files or action-skill prose +into the runner; doing so creates a second, drifting quality policy. + +## Review a complete app folder + +For a committed app, generated fixture, or source tree that has no meaningful +diff, supply the app's root directory as `folder-path`. The review scope is +every relevant file below that directory, including `app.json` and AL source. +The folder does not need to be a Git repository. + +With the standalone plugin installed, start a fresh Copilot session in the app +folder and ask: + +> Use the installed `al-code-review` skill to review the complete Business +> Central app in this folder. Execute every dispatched review domain and return +> the complete BCQuality findings report. + +The adapter maps this request to `folder-path`; Entry routes it to the broad +review super-skill. Because a folder is a current-state snapshot, the review +must not invent a previous app version when evaluating comparison-only rules. + +## Minimal runner flow + +1. Give the agent the review input and a task context containing the user's + actual goal, available input types, and any known BC applicability + dimensions. +2. Invoke `skills/entry.md`. Entry prepares the knowledge index and returns the + action skills to run. Do not reproduce its routing logic. +3. Execute every dispatched action skill with the exact input subset in its + dispatch record. Read `skills/read.md` and `skills/do.md` on demand. +4. When an action skill declares `sub-skills`, execute every relevant leaf as a + discrete invocation. Leaves are independent and may be scheduled serially + or concurrently. +5. Collect each complete findings-report into `sub-results` in the declared + `sub-skills` order, not completion order. Run the super-skill self-review + only after all leaves have finished. +6. Apply the DO composition, failure, deduplication, reference-integrity, and + outcome rules. Return strict JSON before rendering it for people or another + system. + +The runner must never inspect the diff to skip a review domain. A leaf decides +its own task-level applicability and reports `not-applicable` or +`no-knowledge`. + +## Runner-owned choices + +Keep these settings and behaviors outside BCQuality: + +- coordinator and leaf models; +- serial or concurrent scheduling and maximum concurrency; +- retries, timeouts, and rate-limit handling; +- token, cost, duration, and actual-concurrency telemetry; +- conversion of the findings report into Markdown, annotations, or PR + comments. + +Model selection and requested concurrency are deployment choices, not review +rules. Evaluate them against representative applications before making them a +default. Report actual usage and concurrency only when the host exposes native +evidence; do not infer them from the requested profile. + +## Failure and output checklist + +A compatible runner: + +- invokes every worklisted leaf exactly once unless a documented retry replaces + a failed attempt; +- keeps leaf contexts isolated and passes only the inputs they declare; +- preserves every leaf report, including failed reports, in `sub-results`; +- excludes unreliable findings from failed leaves and returns `partial` when + only part of the review is reliable; +- orders `sub-results` by the declared worklist and orders rendered findings + deterministically; +- calculates top-level severity counts from deduplicated top-level findings, + not by summing leaf counts; +- preserves knowledge paths verbatim and verifies references before publishing; +- records the BCQuality commit or release used for the run. + +BC-ALAgents, AL-Go, a Copilot custom agent, or a small host-native plugin can +all implement this runner contract. They remain optional consumers: +BCQuality's knowledge and skills stay independent of their orchestration +choices. diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md index 43a2e20..3ba30e4 100644 --- a/microsoft/skills/review/al-appsource-review.md +++ b/microsoft/skills/review/al-appsource-review.md @@ -4,7 +4,7 @@ id: al-appsource-review version: 1 title: AL AppSource review description: Performs an AL AppSource review against source and app metadata guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source and app metadata changes against the `appsource` 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). AppSource findings are narrow by design — they apply when the diff touches AppSourceCop configuration, AL object or extension-member names, or AppSource-facing `app.json` metadata. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. AppSource findings are narrow by design — they apply when the review scope contains AppSourceCop configuration, AL object or extension-member names, or AppSource-facing `app.json` metadata. The skill returns `not-applicable` when none of those apply. ## Source diff --git a/microsoft/skills/review/al-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md index 82aeda0..767c9af 100644 --- a/microsoft/skills/review/al-breaking-changes-review.md +++ b/microsoft/skills/review/al-breaking-changes-review.md @@ -4,7 +4,7 @@ id: al-breaking-changes-review version: 1 title: AL breaking changes review description: Reviews AL source changes against breaking-changes guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `breaking-changes` 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. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 9b3f934..41f0f78 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -4,7 +4,7 @@ id: al-code-review version: 1 title: AL code review description: Reviews AL source changes by composing the AL review leaf skills, one per knowledge domain. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -35,7 +35,7 @@ Reviews AL source changes by composing the leaf AL review skills. This is the ca `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`. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract, extended with `sub-results` and — when applicable — `skipped-sub-skills`. ## Source @@ -63,18 +63,18 @@ The worklist is the list of sub-skills judged relevant by the previous step. Eve ### Execution discipline (mandatory) -The Action step is a sequence of **discrete iterations**, not one combined generation. The contract requires the super-skill to invoke each sub-skill in turn and then perform a self-review pass. Concretely this means: +The Action step consists of **discrete leaf invocations**, not one combined generation. Invocation scheduling belongs to the orchestrator: independent leaves may run serially or concurrently, but their evaluation contexts and findings-reports remain isolated. Concretely this means: - **Isolate leaf invocations when the host supports it.** For fast/small models, each sub-skill SHOULD run in a fresh model call or child context containing only the task input, READ/DO contracts, the leaf instructions, a domain-filtered slice of the current knowledge index, and articles that leaf worklists. Preserve each index row's exact `path`; the leaf must copy references from that slice. The coordinator then collects the resulting JSON. This is the preferred fast-model profile: it bounds context, prevents later leaves from being skipped as attention is exhausted, and removes any reason to synthesize article paths. -- Treat each sub-skill in the worklist as its own pass: read the sub-skill's instructions, apply its Source → Relevance → Worklist → Action steps to the orchestrator-supplied inputs, and produce that sub-skill's complete findings-report before moving on. +- Treat each sub-skill in the worklist as its own pass: read the sub-skill's instructions, apply its Source → Relevance → Worklist → Action steps to the orchestrator-supplied inputs, and produce that sub-skill's complete findings-report independently. - Do not collapse multiple sub-skills into one shared reasoning step. Each sub-skill has a distinct knowledge subset and a distinct evaluation procedure; sharing one rolled-up scan dilutes per-skill attention and causes leaves to silently underreport (this has been observed in production: leaf skills returned empty `findings[]` while their standalone runs against the same diff produced multiple matches). - The agent self-review pass is its own final iteration. Begin it only after every sub-skill in the worklist has completed and its sub-result is recorded. -- Sub-skills are independent: re-walking the diff once per sub-skill is correct and expected. The output schema accommodates this — `sub-results` carries one entry per sub-skill, each a complete findings-report. +- Sub-skills are independent: re-walking the diff once per sub-skill is correct and expected. The output schema accommodates this — `sub-results` carries one entry per sub-skill, each a complete findings-report, in the frontmatter `sub-skills` order regardless of completion order. - When isolated calls are unavailable and the current model cannot finish every leaf within its budget, return `partial` with completed `sub-results` and name the first unevaluated sub-skill in `outcome-reason`. Never silently mark the remaining leaves clean. ### Roll up sub-skill findings -For each sub-skill in the worklist, executed one at a time per the discipline above: +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`. 2. Capture the sub-skill's complete findings-report verbatim and append it to `sub-results`. @@ -116,7 +116,7 @@ Sub-skills MAY also emit `suggested-code` when their knowledge file unambiguousl ### 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). +Calculate `summary.counts` from the final top-level `findings[]`, after failed sub-results have been excluded and duplicates have been merged. Aggregate `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`. diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 2386613..01a983a 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -4,7 +4,7 @@ id: al-data-modeling-review version: 1 title: AL data-modeling review description: Performs an AL data-modeling review against guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `data-modeling` 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). Data-modeling findings are narrow by design — they apply when the diff touches setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, or audit fields. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design — they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, or audit fields. The skill returns `not-applicable` when none of those apply. ## Source diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index 39851ac..bc4598a 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-review.md @@ -4,7 +4,7 @@ id: al-error-handling-review version: 1 title: AL error handling review description: Reviews AL source changes against error-handling guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `error-handling` 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. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index c854f1c..7248a45 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-review.md @@ -4,7 +4,7 @@ id: al-events-review version: 1 title: AL events review description: Reviews AL source changes against events-and-subscribers guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `events` 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. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-interfaces-review.md b/microsoft/skills/review/al-interfaces-review.md index 0031e8f..885cd0b 100644 --- a/microsoft/skills/review/al-interfaces-review.md +++ b/microsoft/skills/review/al-interfaces-review.md @@ -4,7 +4,7 @@ id: al-interfaces-review version: 1 title: AL interfaces review description: Reviews AL source changes against interface and enum-with-implementation guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `interfaces` 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. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index bf2f4e8..d4738ac 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -4,7 +4,7 @@ id: al-performance-review version: 1 title: AL performance review description: Reviews AL source changes against performance guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `performance` 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. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index 0bbd8ae..469000c 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -4,7 +4,7 @@ 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] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] 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. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-query-review.md b/microsoft/skills/review/al-query-review.md index c4ea895..3681b23 100644 --- a/microsoft/skills/review/al-query-review.md +++ b/microsoft/skills/review/al-query-review.md @@ -4,7 +4,7 @@ id: al-query-review version: 1 title: AL Query review description: Reviews AL Query objects and Query instance usage against BCQuality guidance. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index 8472afe..5d998c9 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -4,7 +4,7 @@ id: al-security-review version: 1 title: AL security review description: Reviews AL source changes against security guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `security` 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. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index bffef4d..223fbbd 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -4,7 +4,7 @@ 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] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -18,7 +18,7 @@ Reviews AL source changes against the `style` knowledge domain in BCQuality and 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. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-telemetry-review.md b/microsoft/skills/review/al-telemetry-review.md index 4a758f0..4b50a9e 100644 --- a/microsoft/skills/review/al-telemetry-review.md +++ b/microsoft/skills/review/al-telemetry-review.md @@ -4,7 +4,7 @@ id: al-telemetry-review version: 1 title: AL telemetry review description: Performs an AL telemetry review against guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `telemetry` 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). Telemetry findings are narrow by design — they apply when the diff emits, wraps, or changes custom telemetry through `Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, or related telemetry helpers. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Telemetry findings are narrow by design — they apply when the review scope emits, wraps, or changes custom telemetry through `Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, or related telemetry helpers. The skill returns `not-applicable` when none of those apply. ## Source diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index 8bad734..ed50c46 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-review.md @@ -4,7 +4,7 @@ id: al-testing-review version: 1 title: AL testing review description: Performs an AL testing review against guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `testing` 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). Testing findings are narrow by design — they apply when the diff touches test codeunits, test runners, test methods, handlers, assertions, or fixture construction. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Testing findings are narrow by design — they apply when the review scope contains test codeunits, test runners, test methods, handlers, assertions, or fixture construction. The skill returns `not-applicable` when none of those apply. ## Source diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index c0af5af..c04a30a 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -4,7 +4,7 @@ id: al-ui-review version: 1 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] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al, javascript] @@ -18,7 +18,7 @@ Reviews AL page source and control add-in UI files against the `ui` knowledge do 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 implement Business Central control add-ins, including their client-service communication. The skill returns `not-applicable` when the diff contains no page or control add-in 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. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md index 667ea32..2bb1877 100644 --- a/microsoft/skills/review/al-upgrade-review.md +++ b/microsoft/skills/review/al-upgrade-review.md @@ -4,7 +4,7 @@ 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] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] 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. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Upgrade findings are narrow by design — they apply when the review scope contains upgrade codeunits, install codeunits, table schema, enums, or objects under migration namespaces. The skill returns `not-applicable` when none of those apply. ## Source diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md index cfa6fdd..e818e46 100644 --- a/microsoft/skills/review/al-web-services-review.md +++ b/microsoft/skills/review/al-web-services-review.md @@ -4,7 +4,7 @@ id: al-web-services-review version: 1 title: AL web services review description: Reviews AL API surfaces and webhook integration handlers against web-services guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al, javascript] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `web-services` 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. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/skills/README.md b/skills/README.md index 3d8fdfb..b8d3fd3 100644 --- a/skills/README.md +++ b/skills/README.md @@ -57,4 +57,4 @@ each review domain to run in an isolated context. These contracts are stable. Changes require a PR approved by both maintainers. -For the end-to-end flow — from orchestrator trigger through to findings integration — see [`../agent-consumption.md`](../agent-consumption.md). For the high-level project framing, see [`../README.md`](../README.md). +For the end-to-end flow — from orchestrator trigger through to findings integration — see [How agents consume BCQuality](../docs/agent-consumption.md). For the high-level project framing, see [`../README.md`](../README.md). diff --git a/skills/al-code-review/SKILL.md b/skills/al-code-review/SKILL.md index 991a771..df54914 100644 --- a/skills/al-code-review/SKILL.md +++ b/skills/al-code-review/SKILL.md @@ -1,6 +1,6 @@ --- name: al-code-review -description: Review Business Central AL code changes using BCQuality's curated rules. Use for an AL pull request, working-tree diff, branch, or individual AL file when BCQuality is installed as a standalone plugin. +description: Review Business Central AL code using BCQuality's curated rules. Use for an AL app folder, pull request, working-tree diff, branch, or individual AL file when BCQuality is installed as a standalone plugin. --- # AL code review @@ -21,8 +21,11 @@ context and execute the resulting dispatch. - Copy the caller's actual request verbatim into `goal`; do not replace a focused request such as "review performance" with a generic full-review goal. - - Set `inputs-available` to the inputs actually available to the review, - normally `pr-diff` for changes or `file-path` for one file. + - Set `inputs-available` to the inputs actually available to the review: + `folder-path` for an app or source folder, `pr-diff` for changes, or + `file-path` for one file. Pass the caller's actual path with the selected + input type; for a whole-app request in the current working directory, use + that directory as the `folder-path`. - Set `technologies: [al]` when the input is known to be AL. - Pass `bc-version`, `countries`, and `application-area` only when supplied or reliably determined. @@ -66,4 +69,3 @@ where a consumer prunes its checkout to policy before the agent runs and the index is rebuilt over the pruned tree. Treat `BCQUALITY_ENABLED_LAYERS` as a selection filter, never as a security boundary. A host that needs a genuine deny mechanism must prune the installed tree itself. - diff --git a/skills/do.md b/skills/do.md index a79c5fc..5234437 100644 --- a/skills/do.md +++ b/skills/do.md @@ -56,7 +56,24 @@ application-area: [all] `bc-version`, `technologies`, `countries`, `application-area` are optional filters that let an orchestrator pre-select applicable skills for a task. They follow the same semantics as in READ. -`inputs` is a list of abstract input types the skill **accepts**. Standard values: `pr-diff`, `object-list`, `file-path`, `repository`, `telemetry-query`. Semantics are any-of: the orchestrator supplies whichever listed input types it has, and the skill is invoked with a non-empty subset of its declared `inputs`. A skill that cannot proceed with the supplied subset MUST return `outcome: "not-applicable"`. `outputs` is always a single-element list naming the output kind; today only `findings-report` is defined. +`inputs` is a list of abstract input types the skill **accepts**. Standard values: +`pr-diff`, `object-list`, `file-path`, `folder-path`, `repository`, and +`telemetry-query`. Semantics are any-of: the orchestrator supplies whichever +listed input types it has, and the skill is invoked with a non-empty subset of +its declared `inputs`. A skill that cannot proceed with the supplied subset +MUST return `outcome: "not-applicable"`. `outputs` is always a single-element +list naming the output kind; today only `findings-report` is defined. + +`file-path` is one file. `folder-path` is a directory whose recursively +contained files form the complete current-state input, such as a Business +Central app folder containing `app.json` and AL source. The input value is the +actual path, not merely the name of the input type. The agent MUST enumerate +the folder rather than reducing it to one representative file. + +Review skills use terms such as "diff", "changed files", and "changed code" as +shorthand for the supplied review scope. For `folder-path`, every relevant file +under the folder is in scope. A folder supplies no historical baseline: +comparison-only rules MUST NOT infer a prior state that was not provided. `sub-skills` is an optional field. When present and non-empty, the skill is a **super-skill** that composes other action skills; see *Composition* below. Values are repo-relative paths to action-skill files. @@ -231,7 +248,7 @@ Omit `suggested-code` only when the appropriate fix depends on context the skill - `reference` — the suppressed file (same object shape as `findings[].references`). - `reason` — `layer-precedence` when another layer won under READ's precedence rules; `configuration` when the consumer disabled the file's layer. -**`sub-results`** — super-skills only. Array of complete findings-reports, one per sub-skill that was invoked (i.e., every sub-skill not listed in `skipped-sub-skills`). Each entry MUST itself conform to this output contract. Leaf skills MUST NOT emit `sub-results`. +**`sub-results`** — super-skills only. Array of complete findings-reports, one per sub-skill that was invoked (i.e., every sub-skill not listed in `skipped-sub-skills`). Each entry MUST itself conform to this output contract. Entries MUST appear in the worklist's declared order, regardless of invocation or completion order. Leaf skills MUST NOT emit `sub-results`. **`skipped-sub-skills`** — super-skills only. Array of sub-skills that were declared in frontmatter but not invoked. `reason` is `configuration` when the orchestrator disabled the sub-skill, or `not-applicable` when the super-skill's Relevance step ruled it out. @@ -248,6 +265,20 @@ A **super-skill** is an action skill whose frontmatter declares a non-empty `sub Composition is flat: a super-skill MAY list only leaf skills (skills without their own `sub-skills`). Nested super-skills are not permitted in v1. +### Scheduling boundary + +The super-skill defines which leaves must run, the input and output contracts, +and how their results are composed. It does not prescribe a model, concurrency +limit, retry policy, or telemetry system. Those choices belong to the +orchestrator. + +Each leaf invocation MUST remain a discrete evaluation with its own complete +findings-report. An orchestrator MAY execute independent leaves serially or +concurrently, but MUST invoke every worklisted leaf, preserve `sub-results` in +the declared worklist order, and wait for every invocation to finish before +performing any super-skill self-review or final rollup. Scheduling MUST NOT +change relevance, coverage, failure, reference-integrity, or output semantics. + ### Section interpretation for super-skills The five required sections still apply. Their meaning shifts from knowledge files to sub-skills: @@ -274,7 +305,13 @@ When the worklist is empty (every sub-skill was skipped), `outcome` is `not-appl ### Rolled-up summary -`summary.counts` is the sum of sub-skill counts. `summary.coverage.worklist-size` and `items-evaluated` are the sums across invoked sub-skills. +`summary.counts` counts the findings in the super-skill's final top-level +`findings[]`, after failed sub-results have been excluded and duplicates have +been merged. It MUST NOT be calculated by summing sub-skill counts, because the +same concern may appear in more than one sub-result. + +`summary.coverage.worklist-size` and `items-evaluated` are the sums across +invoked sub-skills whose outcomes are not `failed`. ### Suppression scope diff --git a/skills/entry.md b/skills/entry.md index 0196c35..efa84a1 100644 --- a/skills/entry.md +++ b/skills/entry.md @@ -23,6 +23,7 @@ task-context: inputs-available: # values the orchestrator has ready to pass to a chosen skill - pr-diff - file-path + - folder-path technologies: [al] bc-version: 28 countries: [w1] From a21edfec460e7b8d066d5ca3aaba4875d787edb4 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Wed, 9 Sep 2026 17:07:11 +0200 Subject: [PATCH 66/86] Frame app review as a plugin example (#173) Remove historical naming and external orchestrator references, and present the app-folder review as one example of the broader host-native skill pattern. Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 27 +++++++++++++++------------ docs/README.md | 2 +- docs/agent-consumption.md | 9 ++++++--- docs/standalone-runner.md | 7 +++---- skills/README.md | 2 -- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index f3ea476..823db24 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ A file that *prevents* a false positive — documenting why a pattern is legitim ## 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. +BCQuality contains **knowledge** and **skills**. It does not contain agents. +Agents that consume BCQuality are supplied by the host or orchestrator. ### Knowledge files @@ -60,13 +61,17 @@ Skills define how agents consume knowledge. They come in three flavors: ### Agent bootstrapping -An orchestrator (such as AL-Go) points the agent at BCQuality's URL and provides a task context. The agent's first call is `/skills/entry.md`, which returns a dispatch record naming the action skill(s) to invoke. The agent then invokes the dispatched skills, reading READ and DO on demand. No prior knowledge of BCQuality's structure is baked into the orchestrator — only the convention *"invoke `/skills/entry.md` first."* +A host or orchestrator points the agent at BCQuality and provides a task +context. The agent's first call is `/skills/entry.md`, which returns a dispatch +record naming the action skill(s) to invoke. The agent then invokes the +dispatched skills, reading READ and DO on demand. No prior knowledge of +BCQuality's structure is required beyond the convention *"invoke +`/skills/entry.md` first."* ### Standalone plugin installation -BCQuality can also be installed directly as a plugin to review a complete AL -app folder, a change set, or an individual file. The plugin registers one -host-native skill, +BCQuality can also be installed directly as a plugin so supported hosts can +discover and invoke its host-native skills. The plugin currently registers [`al-code-review`](skills/al-code-review/SKILL.md), which adapts the caller's request to the same Entry protocol used by orchestrators. @@ -76,7 +81,11 @@ For GitHub Copilot CLI: copilot plugin install microsoft/BCQuality ``` -#### Review a complete app folder +#### Example: Review a complete app folder + +This example demonstrates the walk-up pattern with the currently exposed +review skill. Future host-native skills follow the same discovery and +invocation pattern; they do not each require a dedicated README walkthrough. 1. Open the Business Central app folder in GitHub Copilot and start a fresh session after installing the plugin. @@ -94,12 +103,6 @@ a newer BCQuality release later, run: copilot plugin update bcquality ``` -Plugin version `0.2.0` renamed the former `bcquality-al-review` skill to -`al-code-review`; explicit invocations and allowlists using the old skill name -must be updated. The name remains distinct from BC-ALAgents' public -`al-review` skill because current hosts may load plugin skill names into one -shared inventory. - The adapter is intentionally not a second review implementation: ```text diff --git a/docs/README.md b/docs/README.md index c8e8290..466b98e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,6 @@ - [How agents consume BCQuality](agent-consumption.md) explains the operational flow from Entry dispatch through structured findings and integration. - [Build a lightweight standalone review runner](standalone-runner.md) explains - the walk-up app-folder flow and how an external runner can add model + one concrete walk-up skill flow and how an external runner can add model selection, concurrency, retries, and telemetry without moving orchestration into BCQuality. diff --git a/docs/agent-consumption.md b/docs/agent-consumption.md index aaa6772..2cb3761 100644 --- a/docs/agent-consumption.md +++ b/docs/agent-consumption.md @@ -1,13 +1,16 @@ # How agents consume BCQuality -BCQuality is content — knowledge files and skills. It is consumed by agents that live elsewhere (AL-Go, a VS Code extension, a GitHub Agent invocation, etc.). This document explains the end-to-end flow, so that skill authors, orchestrator maintainers, and contributors share one mental model. +BCQuality is content — knowledge files and skills. It is consumed by agents +supplied by a host or orchestrator. This document explains the end-to-end flow +so that skill authors, orchestrator maintainers, and contributors share one +mental model. For the high-level framing and repo structure, start with the [README](../README.md). This document is the operational view. ## The actors -- **Orchestrator** — the tool that triggers work (e.g. AL-Go on a pull request, or a VS Code extension on save). Lives *outside* BCQuality. Knows *when* to run something, not *what* to run. +- **Orchestrator** — the tool that triggers work. Lives *outside* BCQuality. Knows *when* to run something, not *what* to run. - **Agent** — an LLM-driven process spawned by the orchestrator. The agent has no built-in knowledge of BC or of BCQuality's conventions. It knows how to read instructions and call tools. - **BCQuality repo** — two kinds of content: - **Global skills** in `/skills/` — the `entry.md` entry-point skill plus the READ · DO · WRITE contracts that govern the rest of the repo. @@ -21,7 +24,7 @@ action skill: it creates the task context and enters the same flow at Entry. ```mermaid flowchart LR - O[Orchestrator
AL-Go] -->|1 trigger + task context| A[Agent] + O[Host or orchestrator] -->|1 trigger + task context| A[Agent] A -->|2 invoke entry.md| E[Entry
routing skill] E -->|3 dispatch record| A A -->|4 invoke dispatched skill| S[Action skill
e.g. al-code-review] diff --git a/docs/standalone-runner.md b/docs/standalone-runner.md index 605ffcc..8bb1e67 100644 --- a/docs/standalone-runner.md +++ b/docs/standalone-runner.md @@ -97,7 +97,6 @@ A compatible runner: - preserves knowledge paths verbatim and verifies references before publishing; - records the BCQuality commit or release used for the run. -BC-ALAgents, AL-Go, a Copilot custom agent, or a small host-native plugin can -all implement this runner contract. They remain optional consumers: -BCQuality's knowledge and skills stay independent of their orchestration -choices. +A CI integration, custom agent, or small host-native plugin can implement this +runner contract. These remain optional consumers: BCQuality's knowledge and +skills stay independent of their orchestration choices. diff --git a/skills/README.md b/skills/README.md index b8d3fd3..766131f 100644 --- a/skills/README.md +++ b/skills/README.md @@ -48,8 +48,6 @@ This gives the two skill formats distinct roles: The host adapter and internal coordinator deliberately share the `al-code-review` name because they represent the same user-facing operation in their respective formats. Their locations distinguish their roles. The -adapter remains distinct from BC-ALAgents' separately installed `al-review` -skill, avoiding a collision in hosts that use one shared skill inventory. The reference from the adapter to Entry, and from a dispatched super-skill to its leaf skills, is intentional progressive disclosure. It avoids registering every internal BCQuality protocol file as an ambient host skill while allowing From 2b5550c3463017f498a47691f740c684471eaaf8 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Wed, 9 Sep 2026 17:31:03 +0200 Subject: [PATCH 67/86] Improve partner onboarding and documentation navigation (#174) Lead with a complete plugin quick start and add task-oriented usage, troubleshooting, customization, and contribution guides. Preserve the broader plugin framing, correct conflicting contract guidance, support Agents folder reviews, and align repository validation. Convert existing sample references to clickable links without changing knowledge rules. Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/custom-layer-autoclose.md | 36 +-- .github/new-top-level-flag.md | 2 +- .github/scripts/validate_frontmatter.py | 2 +- .github/workflows/flag-new-top-level.yml | 5 +- README.md | 277 ++++++------------ ...ent-permissions-intersect-with-assigner.md | 4 +- .../agent-profile-narrows-visible-ui.md | 4 +- ...gent-setup-page-is-configuration-dialog.md | 4 +- .../agent-setup-source-table-is-temporary.md | 4 +- ...t-setup-table-keyed-by-user-security-id.md | 4 +- ...ssage-error-stops-warning-forces-review.md | 4 +- ...agent-subscribers-only-in-agent-session.md | 4 +- ...ss-app-agent-calls-need-your-public-api.md | 4 +- ...agents-in-install-upgrade-or-background.md | 4 +- ...default-access-controls-least-privilege.md | 4 +- .../get-default-profile-lives-in-the-app.md | 4 +- ...struction-structure-is-role-rules-steps.md | 4 +- ...instructions-describe-work-not-tool-ids.md | 4 +- ...eapply-resource-instructions-on-upgrade.md | 4 +- ...gister-copilot-capability-for-the-agent.md | 4 +- .../agents/set-instructions-as-secrettext.md | 4 +- ...create-agent-does-not-block-code-create.md | 4 +- ...-incoming-review-only-for-trusted-input.md | 4 +- .../use-documented-instruction-keywords.md | 4 +- .../agents/wire-all-three-agent-interfaces.md | 4 +- community/skills/review/al-agents-review.md | 7 +- custom/README.md | 15 +- docs/README.md | 38 ++- docs/agent-consumption.md | 39 ++- docs/contributing.md | 137 +++++++++ docs/customizing-bcquality.md | 173 +++++++++++ docs/standalone-runner.md | 28 +- docs/troubleshooting.md | 59 ++++ docs/using-bcquality.md | 190 ++++++++++++ .../object-affixes-prevent-collisions.md | 4 +- ...ets-cover-setup-and-usage-without-super.md | 4 +- ...object-affix-not-extension-member-affix.md | 4 +- .../choose-access-modifiers-deliberately.md | 4 +- ...lic-members-with-the-obsolete-lifecycle.md | 4 +- ...t-change-published-procedure-signatures.md | 4 +- ...xpose-sensitive-data-through-public-api.md | 4 +- ...not-modify-code-already-marked-obsolete.md | 4 +- ...ce-is-part-of-published-object-identity.md | 4 +- ...e-table-fields-instead-of-deleting-them.md | 4 +- ...ocked-in-referencing-code-not-in-master.md | 4 +- ...table-no-from-number-series-in-oninsert.md | 4 +- ...able-must-delete-dependents-in-ondelete.md | 4 +- ...-date-modified-in-onmodify-and-onrename.md | 4 +- .../setup-table-is-a-singleton.md | 4 +- ...-items-with-insert-not-field-assignment.md | 4 +- ...on-extensions-are-additive-and-top-down.md | 4 +- ...fields-skip-type-mismatch-can-drop-data.md | 4 +- ...-series-codeunit-not-noseriesmanagement.md | 4 +- ...ion-false-suppresses-rename-propagation.md | 4 +- ...is-a-before-image-only-in-some-triggers.md | 4 +- ...ct-validation-errors-with-errorbehavior.md | 4 +- ...type-internal-vs-client-for-diagnostics.md | 4 +- .../fielderror-default-message-logic.md | 4 +- .../error-handling/fielderror-vs-testfield.md | 4 +- ...yfunction-return-disables-try-semantics.md | 4 +- .../prefer-errorinfo-for-actionable-errors.md | 4 +- .../add-new-event-parameters-at-the-end.md | 4 +- .../avoid-loosely-typed-event-parameters.md | 4 +- ...oid-raising-events-inside-try-functions.md | 4 +- ...atic-vs-manual-subscribers-deliberately.md | 4 +- ...lare-event-publishers-local-or-internal.md | 4 +- ...-not-add-ishandled-to-an-existing-event.md | 4 +- ...pass-critical-operations-with-ishandled.md | 4 +- ...ot-change-shipped-event-attribute-flags.md | 4 +- .../do-not-publish-events-inside-loops.md | 4 +- ...process-context-via-manually-bound-flag.md | 4 +- ...-event-parameters-without-abbreviations.md | 4 +- .../name-events-by-publisher-position.md | 4 +- ...er-reusing-or-extending-existing-events.md | 4 +- ...s-over-includesender-in-codeunit-events.md | 4 +- ...orary-record-event-parameters-with-temp.md | 4 +- ...execution-when-ishandled-skips-the-body.md | 4 +- ...hin-onbefore-onafter-integration-events.md | 4 +- ...dled-only-when-the-value-can-carry-over.md | 4 +- ...internal-events-as-subscriber-contracts.md | 4 +- ...dled-to-make-base-behaviour-overridable.md | 4 +- ...n-codeunit-to-interface-for-testability.md | 4 +- ...end-published-interfaces-dont-edit-them.md | 4 +- ...rdinals-with-unknownvalueimplementation.md | 4 +- .../prefer-interface-over-case-branching.md | 4 +- .../set-defaultimplementation-on-enum.md | 4 +- .../addloadfields-in-report-onpredataitem.md | 4 +- .../apply-filters-before-iterating.md | 4 +- .../performance/apply-guards-before-get.md | 4 +- ...g-records-before-modify-delete-in-loops.md | 4 +- .../performance/avoid-commit-inside-loops.md | 4 +- ...oid-currpage-update-in-onaftergetrecord.md | 4 +- .../avoid-get-inside-loop-on-large-table.md | 4 +- ...g-globals-in-singleinstance-subscribers.md | 4 +- .../avoid-recordref-in-hot-loop.md | 4 +- ...edundant-get-when-record-already-loaded.md | 4 +- .../avoid-user-prompts-inside-transactions.md | 4 +- ...ber-series-instead-of-getnextno-per-row.md | 4 +- .../boolean-operators-do-not-short-circuit.md | 4 +- .../calcsums-instead-of-calcfields-in-loop.md | 4 +- .../case-true-of-for-long-condition-chains.md | 4 +- .../changecompany-in-loop-drops-caches.md | 4 +- ...e-maintainsiftindex-by-read-write-ratio.md | 2 +- .../codeunit-run-as-atomic-sub-operation.md | 4 +- ...equires-prior-commit-inside-transaction.md | 4 +- ...ssintent-readonly-on-analytical-objects.md | 4 +- ...do-not-locktable-in-read-only-procedure.md | 4 +- .../do-not-modify-in-onaftergetrecord.md | 4 +- ...move-sourcetabletemporary-from-api-page.md | 4 +- .../findset-true-applies-updlock-on-read.md | 4 +- ...owfield-source-key-needs-sumindexfields.md | 4 +- .../guard-event-subscribers-before-db-call.md | 4 +- ...guiallowed-guard-on-pages-used-as-odata.md | 4 +- ...elds-still-calculate-before-bc26-opt-in.md | 4 +- ...nt-inside-write-transaction-holds-locks.md | 4 +- ...mpty-before-findset-is-extra-round-trip.md | 4 +- ...-common-fields-before-branching-on-case.md | 4 +- ...y-primary-key-fields-for-reference-work.md | 4 +- ...ainsqlindex-false-breaks-flowfield-sift.md | 2 +- ...ncompanyopen-subscribers-must-not-do-io.md | 4 +- .../order-case-branches-by-frequency.md | 4 +- ...age-background-tasks-for-expensive-cues.md | 4 +- .../pair-findset-with-next-loop.md | 4 +- ...false-to-insert-when-trigger-not-needed.md | 2 +- ...ord-to-preserve-partial-load-enumerator.md | 4 +- .../prefer-modifyall-over-per-row-modify.md | 4 +- ...-readisolation-over-locktable-for-reads.md | 4 +- ...ted-table-over-extension-on-hot-ledgers.md | 4 +- .../query-results-bypass-primary-key-cache.md | 4 +- .../reset-clears-partial-record-selection.md | 4 +- ...rrentkey-sets-sort-order-not-index-hint.md | 4 +- ...tloadfields-on-write-and-transferfields.md | 4 +- ...e-dedicated-lookup-pages-not-full-lists.md | 4 +- ...se-deleteall-for-filtered-bulk-deletion.md | 4 +- ...nstead-of-findfirst-on-full-primary-key.md | 4 +- .../use-isempty-for-existence-check.md | 4 +- ...etautocalcfields-for-per-row-flowfields.md | 4 +- .../use-setloadfields-for-partial-records.md | 4 +- ...unction-for-error-catching-not-rollback.md | 4 +- .../validate-on-partial-record-forces-jit.md | 4 +- .../avoid-strsubstno-prebuild-before-error.md | 4 +- ...a-classification-required-on-pii-fields.md | 4 +- ...-telemetry-classification-and-errortype.md | 4 +- ...eaturetelemetry-customdimensions-no-pii.md | 4 +- ...retelemetry-logerror-implicit-errortext.md | 4 +- ...lowfilter-classification-systemmetadata.md | 2 +- ...asterrortext-customer-content-in-errors.md | 4 +- .../no-pii-in-telemetry-message-string.md | 4 +- ...tice-consent-for-external-data-transfer.md | 4 +- ...gration-in-privacy-notice-registrations.md | 2 +- ...-logmessage-requires-dataclassification.md | 4 +- ...able-level-data-classification-cascades.md | 2 +- ...g-query-resets-cursor-but-keeps-filters.md | 4 +- .../query/set-query-filters-before-open.md | 4 +- .../security/al-has-no-built-in-htmlencode.md | 4 +- ...avior-attribute-scopes-explicit-commits.md | 4 +- ...pose-permission-sets-with-included-sets.md | 4 +- ...rortext-storage-is-privacy-not-security.md | 2 +- .../guard-bulk-operations-with-istemporary.md | 4 +- ...ndirect-permissions-for-elevated-access.md | 4 +- .../inherent-permissions-minimal-grant.md | 4 +- ...ntegrationevent-must-not-expose-secrets.md | 4 +- ...-var-parameter-bypasses-security-guards.md | 4 +- ...ernal-access-is-not-a-security-boundary.md | 4 +- ...torage-access-must-be-local-or-internal.md | 4 +- ...atedstorage-datascope-module-vs-company.md | 4 +- ...orage-setencrypted-for-sensitive-values.md | 4 +- ...ble-required-when-unwrapping-secrettext.md | 4 +- .../permission-set-avoid-wildcard-grants.md | 4 +- ...2-over-api-keys-for-external-http-calls.md | 4 +- ...tect-sensitive-data-in-temporary-tables.md | 4 +- ...en-with-caller-table-must-not-be-public.md | 4 +- .../security/secrets-isolated-storage.md | 4 +- .../secretstrsubstno-for-composing-secrets.md | 4 +- .../security/secrettext-for-credentials.md | 4 +- .../security/secrettext-with-httpclient.md | 4 +- .../validate-user-configurable-urls.md | 4 +- ...lidatetablerelation-false-on-user-input.md | 4 +- .../abouttitle-abouttext-teaching-tips.md | 4 +- .../style/api-page-camelcase-properties.md | 4 +- .../style/api-page-delayedinsert-true.md | 4 +- .../api-page-entity-naming-singular-plural.md | 4 +- .../style/api-page-version-format.md | 4 +- ...plicationarea-required-on-page-controls.md | 4 +- .../begin-on-same-line-as-then-else-do.md | 4 +- .../style/block-keywords-start-new-line.md | 4 +- .../style/caption-required-on-page-fields.md | 4 +- .../case-action-on-line-after-possibility.md | 4 +- ...sses-parameters-directly-not-strsubstno.md | 4 +- ...dcaption-not-fieldname-in-user-messages.md | 4 +- .../function-call-parentheses-required.md | 4 +- .../label-comment-explains-placeholders.md | 4 +- .../label-locked-for-non-translatable.md | 4 +- .../style/label-suffix-approved-list.md | 4 +- .../style/lowercase-reserved-keywords.md | 4 +- .../style/named-invocations-not-object-ids.md | 4 +- .../no-begin-end-around-single-statement.md | 4 +- .../no-else-after-terminating-statement.md | 4 +- .../no-space-before-method-parenthesis.md | 4 +- ...aption-required-and-matches-membercount.md | 4 +- .../style/single-space-after-not-operator.md | 4 +- .../single-space-around-binary-operators.md | 4 +- .../style/temporary-variable-temp-prefix.md | 4 +- .../style/this-keyword-in-codeunits.md | 4 +- .../style/tooltip-required-on-page-fields.md | 4 +- .../variable-declaration-order-by-type.md | 4 +- .../style/variable-name-must-not-shadow.md | 4 +- .../choose-telemetry-scope-by-audience.md | 4 +- .../feature-uptake-transitions-in-order.md | 4 +- .../feature-usage-only-after-success.md | 4 +- .../keep-custom-dimension-schema-stable.md | 4 +- .../match-verbosity-to-signal-severity.md | 4 +- ...ster-one-telemetry-logger-per-publisher.md | 4 +- .../telemetry-event-id-stable-unique.md | 4 +- ...sserterror-needs-expectederror-and-code.md | 4 +- ...-tests-must-lower-the-execution-context.md | 4 +- ...estisolation-belongs-on-the-test-runner.md | 4 +- ...del-attribute-governs-test-transactions.md | 4 +- .../knowledge/testing/ui-handlers-in-tests.md | 4 +- ...use-library-codeunits-for-test-fixtures.md | 4 +- ...lization-noun-phrase-vs-sentence-phrase.md | 2 +- ...age-resource-ajax-needs-withcredentials.md | 4 +- ...ddin-throttle-al-calls-and-payload-size.md | 4 +- ...ult-descending-sort-on-historical-pages.md | 4 +- .../knowledge/ui/grid-data-table-heuristic.md | 2 +- .../ui/group-labeled-first-child-exception.md | 4 +- microsoft/knowledge/ui/no-nested-grids.md | 2 +- .../semantic-style-in-cuegroup-exception.md | 2 +- ...styles-need-independent-textual-meaning.md | 4 +- .../ui/set-selection-filter-list-scope.md | 4 +- ...on-false-allowed-on-non-editable-fields.md | 2 +- ...how-caption-in-promptdialog-prompt-area.md | 2 +- .../ui/show-caption-in-repeater-allowed.md | 2 +- .../ui/show-caption-on-editable-fields.md | 4 +- ...wmandatory-on-code-required-page-fields.md | 4 +- .../ui/standalone-content-in-layout-table.md | 2 +- .../ui/style-expr-text-vs-boolean.md | 2 +- ...r-intent-requires-data-table-conditions.md | 2 +- ...-request-page-input-in-onqueryclosepage.md | 4 +- ...ing-changes-only-on-tables-without-data.md | 4 +- ...check-only-triggers-do-not-migrate-data.md | 4 +- .../upgrade/datatransfer-for-bulk-init.md | 4 +- ...transfer-skips-triggers-and-subscribers.md | 4 +- .../do-not-block-upgrade-on-data-errors.md | 4 +- .../upgrade/enum-values-additive-at-end.md | 4 +- .../first-install-dataversion-zero-check.md | 4 +- .../knowledge/upgrade/guard-database-reads.md | 4 +- ...initvalue-does-not-update-existing-rows.md | 4 +- ...ll-code-does-not-run-on-version-upgrade.md | 4 +- .../minimize-onvalidate-upgrade-triggers.md | 4 +- .../upgrade/no-external-calls-in-upgrade.md | 4 +- .../obsolete-pending-to-removed-staging.md | 4 +- .../obsoletion-requires-reason-and-tag.md | 4 +- .../register-upgrade-tags-with-subscribers.md | 4 +- ...nonessential-work-via-execution-context.md | 4 +- ...iggers-call-helpers-not-implementations.md | 4 +- .../upgrade/upgrade-codeunit-subtype.md | 4 +- .../use-upgrade-tags-not-version-checks.md | 4 +- ...lues-are-a-contract-by-name-not-ordinal.md | 4 +- ...write-operations-on-read-only-api-pages.md | 4 +- ...pose-only-committed-data-from-api-reads.md | 4 +- .../expose-operations-as-bound-actions.md | 4 +- .../expose-systemid-as-the-api-key.md | 4 +- ...-parts-on-systemid-and-set-multiplicity.md | 4 +- .../set-required-api-page-properties.md | 4 +- ...-adding-not-mutating-published-versions.md | 4 +- ...eligibility-and-validationtoken-renewal.md | 4 +- .../review/al-breaking-changes-review.md | 2 +- microsoft/skills/review/al-code-review.md | 4 +- .../skills/review/al-error-handling-review.md | 2 +- microsoft/skills/review/al-events-review.md | 2 +- .../skills/review/al-performance-review.md | 2 +- microsoft/skills/review/al-security-review.md | 2 +- skills/do.md | 23 +- skills/read.md | 6 +- skills/write.md | 14 +- 276 files changed, 1287 insertions(+), 756 deletions(-) create mode 100644 docs/contributing.md create mode 100644 docs/customizing-bcquality.md create mode 100644 docs/troubleshooting.md create mode 100644 docs/using-bcquality.md diff --git a/.github/custom-layer-autoclose.md b/.github/custom-layer-autoclose.md index f0b059e..800d5fd 100644 --- a/.github/custom-layer-autoclose.md +++ b/.github/custom-layer-autoclose.md @@ -1,23 +1,19 @@ -Hey @{{AUTHOR}} 👋 +Thank you for contributing, @{{AUTHOR}}. -First off — thank you for jumping in and experimenting! It's awesome to see people pushing on the framework. 🎉 +This PR was closed automatically because it changes custom-layer content. +`custom/` is reserved for organization-specific knowledge and skills in +**your own fork**, not the shared upstream repository. -That said, let me gently redirect you, because I think there's a small but important misunderstanding about how the `custom` layer is meant to work: +Keep company-only rules in your fork and point your host at that copy. The +[customization guide](https://github.com/microsoft/BCQuality/blob/main/docs/customizing-bcquality.md) +shows the complete flow. -The `custom` layer in *this* repo isn't a destination for PRs — it's the designated sandbox inside **your own fork**. Think of it as the "your timeline" branch of the multiverse 🌌: this repo is canon, your fork is where you get to remix the lore without needing anyone's approval. That's the whole point of the layer existing — so you *don't* have to upstream your team-specific or experimental work. - -The intended workflow is: - -1. 🍴 **Fork** BCQuality to your own GitHub account -2. Clone *your fork* locally -3. Drop your custom agents and knowledge into the `custom` layer **there** -4. Commit and push to your fork — no PR back to upstream needed for custom stuff - -That way you get full control, your changes survive upstream updates cleanly, and you can pull in new core releases from this repo whenever you want. ✨ - -**Now — here's the fun part:** if while building out your fork you discover knowledge, patterns, or agents that you think would genuinely benefit *everyone* using BCQuality (not just your team), that's exactly what the `/community` layer is for! 🌟 PRs to `/community` here in the upstream repo are absolutely welcome and encouraged — it's how the collective hive mind 🧠 levels up. So please: tinker in your fork, and when you strike gold that's worth sharing, send it our way via `/community`. - -Going to close this PR for now (since it's targeting `custom` rather than `/community`), but please don't read it as a "no" — it's a "yes, but let's route it correctly." 🙏 Happy to help if you hit any snags spinning up your fork, and genuinely looking forward to seeing what you contribute to `/community` down the line. +If the guidance is useful to everyone, submit it to the layer that owns the +domain: Microsoft-owned domains belong under `microsoft/knowledge/`, even +when contributed by a partner; Community-owned domains belong under +`community/knowledge/`. See +[Contributing](https://github.com/microsoft/BCQuality/blob/main/docs/contributing.md). +BCQuality contains knowledge and skills, not agents.
Files in this PR that triggered the auto-close @@ -25,7 +21,5 @@ Going to close this PR for now (since it's targeting `custom` rather than `/comm {{FILES}}
-May your merges be conflict-free. 🚀 - ---- -🤖 This PR was closed automatically by the `Guard custom layer` workflow because it adds or changes content under `/custom/`. If you were only updating the template (`custom/README.md` or a `.gitkeep`), a maintainer can re-open it. If you think this was closed in error, just comment here. +Template changes to `custom/README.md` and `.gitkeep` files are allowed. If +you believe this closure was a mistake, comment here for maintainer review. diff --git a/.github/new-top-level-flag.md b/.github/new-top-level-flag.md index 3d297ea..9656922 100644 --- a/.github/new-top-level-flag.md +++ b/.github/new-top-level-flag.md @@ -3,7 +3,7 @@ {{ENTRIES}} -This isn't a block — just a flag. 🚩 New top-level folders and files are *usually* unintended (a stray export, a tool's scratch dir, or content that meant to land inside an existing layer like `/community/knowledge/`). BCQuality keeps a deliberately small root: `.github/`, `community/`, `custom/`, `microsoft/`, `skills/`, and `tools/`, plus a handful of root docs. +This isn't a block — just a flag. 🚩 New top-level folders and files are *usually* unintended (a stray export, a tool's scratch dir, or content that meant to land inside an existing layer). BCQuality keeps a deliberately small root: plugin metadata, `community/`, `custom/`, `microsoft/`, `skills/`, `tools/`, `docs/`, `evaluation/`, `.github/`, and a handful of root docs. Partner guides belong under `docs/`; shared knowledge belongs beside the skill that owns its domain. **If this was intentional** and the new entry genuinely belongs at the repo root, a maintainer can review and merge as normal — no action needed beyond a quick sanity check. **If it wasn't**, please move the content into the right existing layer (or drop it) and push an update. 🙏 diff --git a/.github/scripts/validate_frontmatter.py b/.github/scripts/validate_frontmatter.py index b0076cc..f422d53 100644 --- a/.github/scripts/validate_frontmatter.py +++ b/.github/scripts/validate_frontmatter.py @@ -45,7 +45,7 @@ ENTRY_SKILL_REQUIRED_KEYS = {"kind", "id", "version", "title"} HOST_SKILL_REQUIRED_KEYS = {"name", "description"} STANDARD_INPUTS = { - "pr-diff", "object-list", "file-path", "repository", "telemetry-query", + "pr-diff", "object-list", "file-path", "folder-path", "repository", "telemetry-query", } ALLOWED_OUTPUTS = {"findings-report"} VALID_SAMPLE_KINDS = {"good", "bad"} diff --git a/.github/workflows/flag-new-top-level.yml b/.github/workflows/flag-new-top-level.yml index 31ab105..865a120 100644 --- a/.github/workflows/flag-new-top-level.yml +++ b/.github/workflows/flag-new-top-level.yml @@ -40,11 +40,12 @@ jobs: // Known, intended repository root. Anything else added at the root // is flagged for a human to eyeball. const ALLOWED_DIRS = new Set([ - '.claude-plugin', '.github', 'community', 'custom', 'microsoft', 'skills', 'tools', + '.claude-plugin', '.github', 'community', 'custom', 'docs', 'evaluation', + 'microsoft', 'skills', 'tools', ]); const ALLOWED_FILES = new Set([ '.gitignore', 'CODEOWNERS', 'LICENSE', 'README.md', - 'SECURITY.md', 'agent-consumption.md', + 'SECURITY.md', 'plugin.json', ]); const MARKER = ''; diff --git a/README.md b/README.md index 823db24..9eeee91 100644 --- a/README.md +++ b/README.md @@ -1,236 +1,125 @@ # BCQuality -Quality skills and knowledge for Business Central development. +Quality skills and knowledge that help AI tools make better Business Central +development decisions: catch BC-specific defects, avoid misleading advice, +and explain findings with references you can read. -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. +BCQuality contains **knowledge and reusable skills**, not agents or a Business +Central extension. Your host supplies the agent. You can install the content +as a plugin, use it from another integration, or browse the knowledge directly. -## What belongs here +## Quick start -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. - -A file that *prevents* a false positive — documenting why a pattern is legitimate so the agent stops flagging it — is as valid as one that catches a defect: negative clarifications are first-class knowledge files. What never belongs is a BC fact hard-coded into a skill. Skills are finders and appliers; knowledge files are what the agent knows. See [`skills/do.md`](skills/do.md) and [`skills/write.md`](skills/write.md). - -## What's in this repo - -BCQuality contains **knowledge** and **skills**. It does not contain agents. -Agents that consume BCQuality are supplied by the host or orchestrator. - -### Knowledge files - -Atomic markdown files with YAML frontmatter. Each file covers one concern — one thing an agent would cite when reviewing or generating code. Knowledge files live in three layers: - -- **`/microsoft/`** — Microsoft-endorsed layer. - - `/microsoft/knowledge/` — Platform guardrails, official guidance. - - `/microsoft/skills/` — Microsoft-endorsed action skills. -- **`/community/`** — BC community layer. - - `/community/knowledge/` — Community patterns and shared guidance. - - `/community/skills/` — Community-contributed action skills. - -- **`/custom/`** — Partner- and customer-specific overrides. Empty by default; populated in forks. - - `/custom/knowledge/` — Organization-specific knowledge files. - - `/custom/skills/` — Organization-specific action skills. - -All three layers are enabled by default when an agent consumes BCQuality. In the shared upstream layers, an action skill and the canonical knowledge it owns should live together: knowledge used by a Microsoft-endorsed skill belongs in `/microsoft/`, while `/community/` holds community-owned skills and their related knowledge. A split is acceptable briefly while a skill or corpus is being promoted, but it should not be the steady state. The `/custom/` layer remains the intentional exception because it overrides shared content in consumer forks. - -Layer authority follows review and ownership, not the contributor's affiliation. Community contributions to a Microsoft-owned knowledge domain can therefore be accepted directly into `/microsoft/`; content can also be promoted from Community to Microsoft-endorsed once its owning skill is promoted. - -### Skills - -Skills define how agents consume knowledge. They come in three flavors: - -- **The entry-point skill** ([`skills/entry.md`](skills/entry.md)) — the first skill an agent invokes at runtime. Given a task context (goal, available inputs, technologies, BC version, etc.), it returns a **dispatch record** naming the action skill or skills to invoke next. Routing logic lives here, not in the orchestrator. - -- **Meta-skill contracts** (`/skills/`) — three stable references that define the rest of the repo: - 1. **Schema + Use** (READ, [`skills/read.md`](skills/read.md)) — how to read a knowledge file: interpret frontmatter, parse sections, understand layer precedence. Any agent or skill that reads knowledge files depends on it. - 2. **Action Skill** (DO, [`skills/do.md`](skills/do.md)) — the template every action skill follows. Defines the four-step pattern (Source → Relevance → Worklist → Action) and the structured output format that orchestrators expect. - 3. **New Knowledge** (WRITE, [`skills/write.md`](skills/write.md)) — how to author a valid knowledge file. References Schema + Use for the format specification and adds authoring rules (atomicity, section guidance). - - 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/review/al-code-review.md`](microsoft/skills/review/al-code-review.md) (super-skill), which composes the AL review leaf skills under [`microsoft/skills/review/`](microsoft/skills/review/) — one per knowledge domain. - -### Agent bootstrapping - -A host or orchestrator points the agent at BCQuality and provides a task -context. The agent's first call is `/skills/entry.md`, which returns a dispatch -record naming the action skill(s) to invoke. The agent then invokes the -dispatched skills, reading READ and DO on demand. No prior knowledge of -BCQuality's structure is required beyond the convention *"invoke -`/skills/entry.md` first."* +The walkthrough below uses **GitHub Copilot CLI in a terminal**, not the +Copilot Chat panel in VS Code. First +[install Copilot CLI and sign in](https://docs.github.com/en/copilot/get-started/cli-quickstart). +Your account and organization policy must allow its use. You do not need to +clone BCQuality, build a runner, or deploy an app to Business Central for this +source-review example. ### Standalone plugin installation -BCQuality can also be installed directly as a plugin so supported hosts can -discover and invoke its host-native skills. The plugin currently registers -[`al-code-review`](skills/al-code-review/SKILL.md), which adapts the caller's -request to the same Entry protocol used by orchestrators. +Run these commands in your terminal: -For GitHub Copilot CLI: - -```shell +```powershell copilot plugin install microsoft/BCQuality +copilot plugin list ``` -#### Example: Review a complete app folder +The list should include `bcquality`. The plugin currently exposes the +[`al-code-review`](skills/al-code-review/SKILL.md) skill. Installation and skill +discovery are the general pattern; reviewing an app is one example of using it. -This example demonstrates the walk-up pattern with the currently exposed -review skill. Future host-native skills follow the same discovery and -invocation pattern; they do not each require a dedicated README walkthrough. +### Example: Review a complete app folder -1. Open the Business Central app folder in GitHub Copilot and start a fresh - session after installing the plugin. -2. Ask: +Start a **new** CLI session in your own app folder, replacing the example path: - > Use the installed `al-code-review` skill to review the complete Business - > Central app in this folder. Execute every dispatched review domain and - > return the complete BCQuality findings report. - -That is the complete walk-up flow. The folder does not need to be a Git -repository; BCQuality reviews `app.json` and the AL source below it. To pick up -a newer BCQuality release later, run: - -```shell -copilot plugin update bcquality +```powershell +cd "C:\Repos\MyBusinessCentralApp" +copilot ``` -The adapter is intentionally not a second review implementation: +Approve access only to a project you trust, then ask: -```text -standalone host skill: skills/al-code-review/SKILL.md - -> routing contract: skills/entry.md - -> review coordinator: microsoft/skills/review/al-code-review.md - -> domain review leaves -``` +> Use the installed al-code-review skill to review the complete Business Central +> app in this folder without changing my source files. Return the complete +> BCQuality findings report. -Only the first file follows the host's `SKILL.md` packaging format. The -remaining files are BCQuality's internal protocol and layered action skills. -Entry remains the single owner of routing and index preparation; -`al-code-review.md` remains the single owner of broad-review composition. This -separation keeps standalone installation available without duplicating those -policies in the plugin adapter. +The folder should contain `app.json` and your AL source; it does **not** need +to be a Git repository. On macOS or Linux, use your app's local path instead. -Note that a plugin install ships the entire tree, so `BCQUALITY_ENABLED_LAYERS` -narrows discovery without removing any files. Layer selection is a filter here, -not a deny mechanism — see [the adapter](skills/al-code-review/SKILL.md) for the -difference from the pruned-clone model. +Expect a report for each selected review, with findings, source locations, +severity, confidence, and references to the relevant guidance. Some hosts show +the structured JSON directly. `completed` with no findings means nothing was +flagged in that review's scope; `partial` or `failed` is **not** a clean result. +See [reading your results](docs/using-bcquality.md#reading-your-results). -The host adapter and internal action skill intentionally share the -`al-code-review` name: they expose the same operation in two different skill -formats. Their paths make the boundary explicit. The adapter lives under -`skills/al-code-review/SKILL.md`; the internal Microsoft-layer coordinator -lives at `microsoft/skills/review/al-code-review.md`. +[PowerShell 7](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell) +(`pwsh`) is recommended for fast knowledge discovery. If it is unavailable, +the review can still discover knowledge by reading the folders. -Partners that want model selection, parallel leaf execution, retries, or usage -telemetry can add a thin runner outside BCQuality. See -[Build a lightweight standalone review runner](docs/standalone-runner.md) for the -integration contract and a minimal implementation checklist. Architecture and -partner guides are collected in the [documentation index](docs/README.md). +## Documentation -## Knowledge file format +| I want to... | Start here | +| --- | --- | +| Review a file, changes, a branch, or a particular concern | [Using BCQuality](docs/using-bcquality.md) | +| Resolve setup problems, incomplete reviews, or incorrect findings | [Troubleshooting and support](docs/troubleshooting.md) | +| Browse the available guidance | [Knowledge by domain](docs/using-bcquality.md#knowledge-by-domain) | +| Configure the plugin or use my organization's rules | [Customizing BCQuality](docs/customizing-bcquality.md) | +| Contribute knowledge or improve a rule | [Contributing](docs/contributing.md) | +| Connect a host, agent, or CI integration | [How agents consume BCQuality](docs/agent-consumption.md) | -Every knowledge file is a markdown file with mandatory YAML frontmatter. Files target under 100 lines (ideal under 50). If two ideas would share a file, split them. - -### Frontmatter schema (v1) - -```yaml ---- -bc-version: [all] # or [26..28], or [26..] for "26 and later" -domain: performance # security | performance | ux | telemetry | ... -keywords: [query, filtering, partial] # free-text tags for retrieval -technologies: [al] # al | javascript | powershell | ... -countries: [w1] # ISO codes, or [w1] -application-area: [all] # finance | manufacturing | jobs | [all] ---- -``` - -All six fields are required. The schema is locked — changes require a PR approved by both maintainers. - -### Sections - -Every knowledge file must contain a `## Description` section. The following sections are optional but recommended: - -- **`## Best Practice`** — the recommended approach -- **`## Anti Pattern`** — what to avoid and why - -Code examples belong in separate files, not in the knowledge file itself. Knowledge files must not contain fenced code blocks. +[All documentation and technical references](docs/README.md). ## Scope -The current curated corpus is focused on **technical AL code review**: Agents, AppSource and compatibility, data modeling, error handling, events, interfaces, performance, privacy, Query objects, security, style, telemetry, testing, UI, upgrade, and web services. These are the domains backed by knowledge files and registered review leaves today. +Today's curated content focuses on **technical AL code review**. It augments +the agent's judgment; it is not an exhaustive BC manual or a substitute for +compilation, analyzers, tests, or human review. See +[coverage and limits](docs/using-bcquality.md#coverage-and-limits) for the +available domains and the difference between a folder review and a comparison. -Business Central functional domains (Finance, Supply Chain Management, Manufacturing, Jobs, Warehousing, Service), PowerShell, pipelines, and Power Platform remain valid future repository scope, but they are **not current coverage claims** until corresponding knowledge and action skills exist. Consumers should derive supported review scope from the live knowledge index and dispatched skills, not from roadmap breadth. +Functional areas such as Finance, Supply Chain Management, Manufacturing, Jobs, +Warehousing, and Service, and technologies such as PowerShell, pipelines, and +Power Platform, remain valid future scope, **not current coverage claims**. -## How agents consume BCQuality +## What's in this repo -Action skills follow a four-step pattern: +Knowledge articles cover one concern each. Skills tell an agent how to find +and apply the relevant knowledge. Both live in three layers: -1. **Source** — which knowledge folders and tags to search -2. **Relevance** — filter by frontmatter (version, technology, country, area) -3. **Worklist** — narrow from N candidates to the M that apply to the current task -4. **Action** — apply the relevant knowledge and produce structured output +| Layer | Purpose | +| --- | --- | +| [Microsoft](microsoft/) | Microsoft-endorsed skills and their knowledge. | +| [Community](community/) | Community-owned skills and their knowledge. | +| [Custom](custom/) | Organization-specific additions and overrides in your own fork. | -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 [How agents consume BCQuality](docs/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 [How agents consume BCQuality](docs/agent-consumption.md). - -## Repository structure - -``` -├── /skills/ # Global: entry-point skill + meta-skill contracts (READ, DO, WRITE) -├── /docs/ # Architecture and partner integration guides -├── /evaluation/ # Neutral good/bad review fixtures and scoring contract -├── /.github/ # Actions and workflows -├── /microsoft/ # Microsoft-endorsed layer -│ ├── /knowledge/ # Knowledge files by domain -│ │ └── // # Each article: .md + optional .good.al / .bad.al -│ └── /skills/ # Microsoft-endorsed action skills -├── /community/ # BC community layer -│ ├── /knowledge/ # Knowledge files by domain -│ │ └── // # Article + sibling samples, same convention -│ └── /skills/ # Community action skills -├── /custom/ # Partner/customer-specific overrides (empty; populated in forks) -│ ├── /knowledge/ -│ └── /skills/ -``` +All three are enabled by default; Custom is empty upstream. You do not need +to configure layers to get started. ## Versioning -BCQuality content is released on demand — roughly monthly, not on every commit. A -release is a `major.minor` value derived from git tags, cut manually via the -`Release version` workflow: pick whether to bump the minor or the major, and it -computes the next version and tags the current `main` as `v{major}.{minor}`. +Update the installed plugin from your terminal, then start a new session: -- Bump the **minor** for the usual periodic content update; bump the **major** - only for a breaking change. -- The minor is a **monotonic counter** — it only ever increments and never - resets, even across a major bump — so it uniquely identifies a release. +```powershell +copilot plugin update bcquality +``` + +Plugin versions and content-release tags are different. For reproducible runs +and organization forks, see [updates and versions](docs/customizing-bcquality.md#updates-and-versions). + +## What belongs here + +Knowledge belongs here when it prevents a BC-specific mistake an otherwise +capable agent would make, including false-positive findings. BC facts belong +in knowledge articles, not skill instructions. See the +[admission test and examples](docs/contributing.md#what-belongs-here). ## Contributing -Contributions are welcome. Before submitting a PR: - -1. Read the knowledge file format above — frontmatter and sections are validated by CI. -2. Keep files atomic: one concern per file, under 100 lines. -3. Target your contribution to the layer that owns the action skill: use `/microsoft/knowledge/` for Microsoft-owned domains and `/community/knowledge/` for knowledge that accompanies a community-owned skill. -4. Adding a BC fact — or stopping the agent from flagging a false positive — is a knowledge file, not a skill edit. If a PR changes *what* a review skill flags, the change almost certainly belongs in a knowledge file. See [`skills/write.md`](skills/write.md). - -CI runs validation on every PR. If your knowledge file has schema violations, missing sections, code blocks, or exceeds 100 lines, the check will fail with a clear error message. - -Companion samples must be referenced by filename from their article, and every referenced sample must exist. The review evaluation corpus under [`evaluation/`](evaluation/) adds one positive and one clean control for every registered AL review leaf; see [`evaluation/README.md`](evaluation/README.md) for credential-free validation and optional fast-model scoring. +Partners are welcome to contribute to the layer that owns the domain, +regardless of affiliation. Start with the [contribution guide](docs/contributing.md). +To report a problem without authoring a rule, see [support](docs/troubleshooting.md#reporting-a-problem). ## License diff --git a/community/knowledge/agents/agent-permissions-intersect-with-assigner.md b/community/knowledge/agents/agent-permissions-intersect-with-assigner.md index 2259f66..7c85a5b 100644 --- a/community/knowledge/agents/agent-permissions-intersect-with-assigner.md +++ b/community/knowledge/agents/agent-permissions-intersect-with-assigner.md @@ -17,13 +17,13 @@ An agent is a user, but it cannot configure users or other agents, and it cannot Document that intersection. Give the agent only the table and page rights its tasks need. Do not add user-setup or permission-assignment pages to the agent profile or permission sets; those operations will fail by design. -See sample: `agent-permissions-intersect-with-assigner.good.al`. +See sample: [`agent-permissions-intersect-with-assigner.good.al`](agent-permissions-intersect-with-assigner.good.al). ## Anti Pattern Permission sets or profiles that include User card, Permission Set Assignment, or agent-admin pages, or comments that the agent runs as SUPER regardless of who assigned it. Detection signal: default access controls or profile including user-administration objects. -See sample: `agent-permissions-intersect-with-assigner.bad.al`. +See sample: [`agent-permissions-intersect-with-assigner.bad.al`](agent-permissions-intersect-with-assigner.bad.al). ## See also diff --git a/community/knowledge/agents/agent-profile-narrows-visible-ui.md b/community/knowledge/agents/agent-profile-narrows-visible-ui.md index 30d286a..914a777 100644 --- a/community/knowledge/agents/agent-profile-narrows-visible-ui.md +++ b/community/knowledge/agents/agent-profile-narrows-visible-ui.md @@ -17,13 +17,13 @@ The agent only sees what its profile shows. Extra actions, views, and Role Cente Ship an agent-specific profile and page customizations: hide unrelated actions, keep descriptive tooltips, add Role Center links to the few pages the agent should open. Prefer fewer navigation hops. -See sample: `agent-profile-narrows-visible-ui.good.al`. +See sample: [`agent-profile-narrows-visible-ui.good.al`](agent-profile-narrows-visible-ui.good.al). ## Anti Pattern Assigning `BUSINESS MANAGER` or `ORDER PROCESSOR` as `GetDefaultProfile` so the agent can do anything. Detection signal: default profile equal to a full-user role with no agent page customizations. -See sample: `agent-profile-narrows-visible-ui.bad.al`. +See sample: [`agent-profile-narrows-visible-ui.bad.al`](agent-profile-narrows-visible-ui.bad.al). ## See also diff --git a/community/knowledge/agents/agent-setup-page-is-configuration-dialog.md b/community/knowledge/agents/agent-setup-page-is-configuration-dialog.md index d844d33..083a83b 100644 --- a/community/knowledge/agents/agent-setup-page-is-configuration-dialog.md +++ b/community/knowledge/agents/agent-setup-page-is-configuration-dialog.md @@ -17,10 +17,10 @@ Instance setup is not a Card or StandardDialog. The toolkit expects `PageType = Declare `PageType = ConfigurationDialog`, host `part(...; "Agent Setup Part")`, and put agent-specific fields in another group. Keep system OK/Cancel. Use a temporary source record and defer persistence until Update, as described in `agent-setup-source-table-is-temporary.md`. Following Microsoft's agent setup samples, set `Extensible = false`. -See sample: `agent-setup-page-is-configuration-dialog.good.al`. +See sample: [`agent-setup-page-is-configuration-dialog.good.al`](agent-setup-page-is-configuration-dialog.good.al). ## Anti Pattern A Card or StandardDialog setup page with no `Agent Setup Part`. Detection signal: setup page ID from `IAgentFactory` / `IAgentMetadata` whose page is not `ConfigurationDialog` or has no `Agent Setup Part`. -See sample: `agent-setup-page-is-configuration-dialog.bad.al`. +See sample: [`agent-setup-page-is-configuration-dialog.bad.al`](agent-setup-page-is-configuration-dialog.bad.al). diff --git a/community/knowledge/agents/agent-setup-source-table-is-temporary.md b/community/knowledge/agents/agent-setup-source-table-is-temporary.md index 8f31aa0..539bc0f 100644 --- a/community/knowledge/agents/agent-setup-source-table-is-temporary.md +++ b/community/knowledge/agents/agent-setup-source-table-is-temporary.md @@ -17,13 +17,13 @@ ConfigurationDialog setup is a draft: the user can Cancel without writing. That Mark the page `SourceTableTemporary = true`. Copy into the temp record on open. Persist the Agent Setup buffer and custom fields only from the close path when the action is not Cancel, using `Agent Setup.GetChangesMade` / `SaveChanges`. -See sample: `agent-setup-source-table-is-temporary.good.al`. +See sample: [`agent-setup-source-table-is-temporary.good.al`](agent-setup-source-table-is-temporary.good.al). ## Anti Pattern A non-temporary source table, or `Insert`/`Modify` on the persisted setup row from field OnValidate. Detection signal: agent `ConfigurationDialog` without `SourceTableTemporary = true`, or database writes before Update. -See sample: `agent-setup-source-table-is-temporary.bad.al`. +See sample: [`agent-setup-source-table-is-temporary.bad.al`](agent-setup-source-table-is-temporary.bad.al). ## See also diff --git a/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.md b/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.md index c59a9f1..c8a8671 100644 --- a/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.md +++ b/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.md @@ -17,10 +17,10 @@ Each agent instance is a user. Instance-specific setup is keyed by that user's ` Give the setup table a Guid field `User Security ID` as the clustered primary key. Other settings are attributes of that key. When the page opens, `Get` or insert by the Guid the Agent Setup part already holds. -See sample: `agent-setup-table-keyed-by-user-security-id.good.al`. +See sample: [`agent-setup-table-keyed-by-user-security-id.good.al`](agent-setup-table-keyed-by-user-security-id.good.al). ## Anti Pattern A setup table keyed by Code, Integer, or with no Guid user key, then mapping one row to every instance. Detection signal: source table of the agent setup page whose primary key is not `User Security ID`. -See sample: `agent-setup-table-keyed-by-user-security-id.bad.al`. +See sample: [`agent-setup-table-keyed-by-user-security-id.bad.al`](agent-setup-table-keyed-by-user-security-id.bad.al). diff --git a/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.md b/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.md index f8c6eb5..9c465f4 100644 --- a/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.md +++ b/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.md @@ -17,10 +17,10 @@ application-area: [all] Validate inbound payloads in analysis: Error when the task must not run; Warning when a human must confirm. For outbound messages, adjust text in this method rather than in a later subscriber. Do not rely on skip-review to bypass warnings. -See sample: `analyze-message-error-stops-warning-forces-review.good.al`. +See sample: [`analyze-message-error-stops-warning-forces-review.good.al`](analyze-message-error-stops-warning-forces-review.good.al). ## Anti Pattern Ignoring analysis entirely, or emitting Warning while documenting that `SetRequiresReview(false)` means unattended run. Detection signal: empty `AnalyzeAgentTaskMessage` plus skip-review on external input. -See sample: `analyze-message-error-stops-warning-forces-review.bad.al`. +See sample: [`analyze-message-error-stops-warning-forces-review.bad.al`](analyze-message-error-stops-warning-forces-review.bad.al). diff --git a/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.md b/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.md index 3d18818..be227f4 100644 --- a/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.md +++ b/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.md @@ -17,10 +17,10 @@ Page-filter tweaks, extra validation, and prompt dialogs for the agent should no On `OnAfterInitialization`, exit unless `Agent Session.IsAgentSession`. Then `BindSubscription` a single-instance codeunit that holds the current task id. Keep those subscribers internal. -See sample: `bind-agent-subscribers-only-in-agent-session.good.al`. +See sample: [`bind-agent-subscribers-only-in-agent-session.good.al`](bind-agent-subscribers-only-in-agent-session.good.al). ## Anti Pattern Event subscribers on `Sales Header` OnAfterInsert that always `Message` the agent, with no `IsAgentSession` guard. Detection signal: agent-only behaviour in a static subscriber that is not bind-gated. -See sample: `bind-agent-subscribers-only-in-agent-session.bad.al`. +See sample: [`bind-agent-subscribers-only-in-agent-session.bad.al`](bind-agent-subscribers-only-in-agent-session.bad.al). diff --git a/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.md b/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.md index 41c1c2f..7d44699 100644 --- a/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.md +++ b/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.md @@ -17,10 +17,10 @@ For isolation, `Agent`, `Agent Task Builder`, and related toolkit codeunits erro Expose a public codeunit in the agent app (`Access = Public`) whose procedures take `User Security ID` and forward to `Agent` / `Agent Task Builder`. Document that surface as the integration contract. Keep toolkit calls inside that app. -See sample: `cross-app-agent-calls-need-your-public-api.good.al`. +See sample: [`cross-app-agent-calls-need-your-public-api.good.al`](cross-app-agent-calls-need-your-public-api.good.al). ## Anti Pattern From app B, calling `Agent.SetDisplayName` or `Agent.Create` with app A's metadata provider. Detection signal: toolkit agent APIs used with an `Agent Metadata Provider` value not declared in the same app. -See sample: `cross-app-agent-calls-need-your-public-api.bad.al`. +See sample: [`cross-app-agent-calls-need-your-public-api.bad.al`](cross-app-agent-calls-need-your-public-api.bad.al). diff --git a/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.md b/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.md index 41d0573..2018de4 100644 --- a/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.md +++ b/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.md @@ -17,10 +17,10 @@ application-area: [all] Create instances from a setup page, a wizard, or another UI-driven path after the user is in a client session. Apply instructions and `Activate` there. For existing companies after an upgrade, document that an admin must open setup; do not create from the upgrade codeunit. -See sample: `do-not-create-agents-in-install-upgrade-or-background.good.al`. +See sample: [`do-not-create-agents-in-install-upgrade-or-background.good.al`](do-not-create-agents-in-install-upgrade-or-background.good.al). ## Anti Pattern `Agent.Create` inside `OnInstallAppPerCompany`, `OnUpgradePerCompany`, or a job-queue codeunit. The call fails at runtime even if it compiles. Detection signal: `Agent.Create` in `Subtype = Install`, `Subtype = Upgrade`, or a non-UI session. -See sample: `do-not-create-agents-in-install-upgrade-or-background.bad.al`. +See sample: [`do-not-create-agents-in-install-upgrade-or-background.bad.al`](do-not-create-agents-in-install-upgrade-or-background.bad.al). diff --git a/community/knowledge/agents/get-default-access-controls-least-privilege.md b/community/knowledge/agents/get-default-access-controls-least-privilege.md index 87349af..c82f71a 100644 --- a/community/knowledge/agents/get-default-access-controls-least-privilege.md +++ b/community/knowledge/agents/get-default-access-controls-least-privilege.md @@ -17,13 +17,13 @@ application-area: [all] Insert only the permission sets the agent needs. For an AL `permissionset` object, use `Scope::System` and the ID of the app that defines it. Recreate permission sets that exist only as user-defined configuration in Business Central as AL objects first. Prefer a dedicated permission set over a full-user role. -See sample: `get-default-access-controls-least-privilege.good.al`. +See sample: [`get-default-access-controls-least-privilege.good.al`](get-default-access-controls-least-privilege.good.al). ## Anti Pattern Empty `GetDefaultAccessControls`, or inserting `SUPER` / `D365 BUS FULL ACCESS` because it made the demo work. Detection signal: Role ID on the default buffer that is a full-user role, or a set that is not in the app. -See sample: `get-default-access-controls-least-privilege.bad.al`. +See sample: [`get-default-access-controls-least-privilege.bad.al`](get-default-access-controls-least-privilege.bad.al). ## See also diff --git a/community/knowledge/agents/get-default-profile-lives-in-the-app.md b/community/knowledge/agents/get-default-profile-lives-in-the-app.md index 25103b5..89c19fb 100644 --- a/community/knowledge/agents/get-default-profile-lives-in-the-app.md +++ b/community/knowledge/agents/get-default-profile-lives-in-the-app.md @@ -17,13 +17,13 @@ application-area: [all] Ship a `profile` object (and page customizations) in the app. In `GetDefaultProfile`, call `Agent.PopulateDefaultProfile` with that profile ID and `NavApp.GetCurrentModuleInfo`. Include UI-exported customizations as AL. -See sample: `get-default-profile-lives-in-the-app.good.al`. +See sample: [`get-default-profile-lives-in-the-app.good.al`](get-default-profile-lives-in-the-app.good.al). ## Anti Pattern Setting `TempAllProfile."Profile ID"` to a client-only profile, or skipping `GetDefaultProfile`. Detection signal: factory default profile ID with no matching `profile` object in the app. -See sample: `get-default-profile-lives-in-the-app.bad.al`. +See sample: [`get-default-profile-lives-in-the-app.bad.al`](get-default-profile-lives-in-the-app.bad.al). ## See also diff --git a/community/knowledge/agents/instruction-structure-is-role-rules-steps.md b/community/knowledge/agents/instruction-structure-is-role-rules-steps.md index 1b56625..4a928fd 100644 --- a/community/knowledge/agents/instruction-structure-is-role-rules-steps.md +++ b/community/knowledge/agents/instruction-structure-is-role-rules-steps.md @@ -17,13 +17,13 @@ The runtime treats instructions as the agent's standing prompt. A one-line goal Store a document that states responsibilities, then non-negotiable guidelines (when to request a review, when not to post), then numbered steps for each task. Keep that text in the resource you pass to `SetInstructions`. -See sample: `instruction-structure-is-role-rules-steps.good.al`. +See sample: [`instruction-structure-is-role-rules-steps.good.al`](instruction-structure-is-role-rules-steps.good.al). ## Anti Pattern A single sentence such as Check customer credit for the sales order. Detection signal: instruction resource or `SetInstructions` payload with no responsibilities / guidelines / steps sections. -See sample: `instruction-structure-is-role-rules-steps.bad.al`. +See sample: [`instruction-structure-is-role-rules-steps.bad.al`](instruction-structure-is-role-rules-steps.bad.al). ## See also diff --git a/community/knowledge/agents/instructions-describe-work-not-tool-ids.md b/community/knowledge/agents/instructions-describe-work-not-tool-ids.md index a1a536a..1487364 100644 --- a/community/knowledge/agents/instructions-describe-work-not-tool-ids.md +++ b/community/knowledge/agents/instructions-describe-work-not-tool-ids.md @@ -17,13 +17,13 @@ Agent tools are the UI the profile exposes. Action names and tool ids change acr Write steps as business outcomes (release the order, set the hold reason). Tell the agent to memorize identifiers it must reuse. Do not hard-code action captions or tool ids. -See sample: `instructions-describe-work-not-tool-ids.good.al`. +See sample: [`instructions-describe-work-not-tool-ids.good.al`](instructions-describe-work-not-tool-ids.good.al). ## Anti Pattern Instructions that say invoke SalesOrder.Post_Promoted or use tool page-42-action-3. Detection signal: instruction text containing Promoted action names or tool identifiers. -See sample: `instructions-describe-work-not-tool-ids.bad.al`. +See sample: [`instructions-describe-work-not-tool-ids.bad.al`](instructions-describe-work-not-tool-ids.bad.al). ## See also diff --git a/community/knowledge/agents/reapply-resource-instructions-on-upgrade.md b/community/knowledge/agents/reapply-resource-instructions-on-upgrade.md index 5345d25..db95258 100644 --- a/community/knowledge/agents/reapply-resource-instructions-on-upgrade.md +++ b/community/knowledge/agents/reapply-resource-instructions-on-upgrade.md @@ -17,10 +17,10 @@ Static instructions stored as an app resource are copied onto an instance only w In the upgrade codeunit, find existing instances of your metadata provider and call `SetInstructions` again with `NavApp.GetResourceAsText`. Guard with an upgrade tag so the rewrite runs once per version that changes the file. -See sample: `reapply-resource-instructions-on-upgrade.good.al`. +See sample: [`reapply-resource-instructions-on-upgrade.good.al`](reapply-resource-instructions-on-upgrade.good.al). ## Anti Pattern Editing only the resource file, or calling `SetInstructions` solely from the first-time setup path. Detection signal: instruction resource in `resourceFolders` with no upgrade procedure that re-applies it. -See sample: `reapply-resource-instructions-on-upgrade.bad.al`. +See sample: [`reapply-resource-instructions-on-upgrade.bad.al`](reapply-resource-instructions-on-upgrade.bad.al). diff --git a/community/knowledge/agents/register-copilot-capability-for-the-agent.md b/community/knowledge/agents/register-copilot-capability-for-the-agent.md index 79dfe44..59171cc 100644 --- a/community/knowledge/agents/register-copilot-capability-for-the-agent.md +++ b/community/knowledge/agents/register-copilot-capability-for-the-agent.md @@ -17,13 +17,13 @@ Each agent type needs a `Copilot Capability` enum value that the factory links a Extend `Copilot Capability` with a unique value. In `OnInstallAppPerDatabase`, call `Copilot Capability.IsCapabilityRegistered` and, if false, `RegisterCapability` with availability, billing type, and a learn-more URL. Point `IAgentFactory` at that capability. -See sample: `register-copilot-capability-for-the-agent.good.al`. +See sample: [`register-copilot-capability-for-the-agent.good.al`](register-copilot-capability-for-the-agent.good.al). ## Anti Pattern Shipping the agent enum without a `Copilot Capability` value, or adding the enum but never calling `RegisterCapability`. Duplicate ordinals across extensions also collide. Detection signal: agent metadata provider with no matching capability registration in an install codeunit. -See sample: `register-copilot-capability-for-the-agent.bad.al`. +See sample: [`register-copilot-capability-for-the-agent.bad.al`](register-copilot-capability-for-the-agent.bad.al). ## See also diff --git a/community/knowledge/agents/set-instructions-as-secrettext.md b/community/knowledge/agents/set-instructions-as-secrettext.md index 0f246f6..4c52863 100644 --- a/community/knowledge/agents/set-instructions-as-secrettext.md +++ b/community/knowledge/agents/set-instructions-as-secrettext.md @@ -17,10 +17,10 @@ Instructions are instance data, not an enum caption. `Agent.SetInstructions` tak Load instruction text from a resource or builder into a `SecretText` variable and call `Agent.SetInstructions(AgentUserSecurityId, Instructions)` after `Create`. Keep one instruction document per instance. -See sample: `set-instructions-as-secrettext.good.al`. +See sample: [`set-instructions-as-secrettext.good.al`](set-instructions-as-secrettext.good.al). ## Anti Pattern Passing a `Label` or `Text` to `SetInstructions`, storing instructions in a setup Text field without wrapping as `SecretText`, or putting the prompt only in a code comment. Detection signal: `SetInstructions` with a non-`SecretText` argument, or no `SetInstructions` after `Create`. -See sample: `set-instructions-as-secrettext.bad.al`. +See sample: [`set-instructions-as-secrettext.bad.al`](set-instructions-as-secrettext.bad.al). diff --git a/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.md b/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.md index 1eb151d..7a935e1 100644 --- a/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.md +++ b/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.md @@ -17,10 +17,10 @@ application-area: [all] Use `ShowCanCreateAgent` to decide discovery. If only agent administrators should see the type, return `Agent System Permissions.CurrentUserHasCanManageAllAgentsPermission`. Enforce extra policy inside your own create API. Never assume UI hiding blocks code. -See sample: `show-can-create-agent-does-not-block-code-create.good.al`. +See sample: [`show-can-create-agent-does-not-block-code-create.good.al`](show-can-create-agent-does-not-block-code-create.good.al). ## Anti Pattern Returning `exit(false)` from `ShowCanCreateAgent` and then documenting that instances cannot be created, while page actions or other apps still call `Agent.Create`. Detection signal: `ShowCanCreateAgent` always false with no matching guard on programmatic create. -See sample: `show-can-create-agent-does-not-block-code-create.bad.al`. +See sample: [`show-can-create-agent-does-not-block-code-create.bad.al`](show-can-create-agent-does-not-block-code-create.bad.al). diff --git a/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.md b/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.md index 449038f..ad91408 100644 --- a/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.md +++ b/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.md @@ -17,10 +17,10 @@ Incoming task messages default to requiring user approval before the agent runs. Leave the default review-on for anything that originated outside your extension. Call `SetRequiresReview(false)` only on messages you constructed from already-authorized BC data. -See sample: `skip-incoming-review-only-for-trusted-input.good.al`. +See sample: [`skip-incoming-review-only-for-trusted-input.good.al`](skip-incoming-review-only-for-trusted-input.good.al). ## Anti Pattern `SetRequiresReview(false)` on simulated email, incoming webhooks, or user-free text. Detection signal: `SetRequiresReview(false)` next to external content with no prior validation. -See sample: `skip-incoming-review-only-for-trusted-input.bad.al`. +See sample: [`skip-incoming-review-only-for-trusted-input.bad.al`](skip-incoming-review-only-for-trusted-input.bad.al). diff --git a/community/knowledge/agents/use-documented-instruction-keywords.md b/community/knowledge/agents/use-documented-instruction-keywords.md index 2150a5d..a65f240 100644 --- a/community/knowledge/agents/use-documented-instruction-keywords.md +++ b/community/knowledge/agents/use-documented-instruction-keywords.md @@ -17,13 +17,13 @@ The agent runtime looks for specific phrases: ask for assistance, request a revi In the instruction resource, use those keywords at the decision points: request a review before posting; write an email only after stating that outbound mail is reviewed; memorize values the later steps need. Pair `Reply` / `Write an email` with an explicit review sentence. -See sample: `use-documented-instruction-keywords.good.al`. +See sample: [`use-documented-instruction-keywords.good.al`](use-documented-instruction-keywords.good.al). ## Anti Pattern Inventing tool-like verbs (call Copilot, click Post_Promoted) or omitting request a review before posting. Detection signal: instruction text that says email the customer with no review keyword. -See sample: `use-documented-instruction-keywords.bad.al`. +See sample: [`use-documented-instruction-keywords.bad.al`](use-documented-instruction-keywords.bad.al). ## See also diff --git a/community/knowledge/agents/wire-all-three-agent-interfaces.md b/community/knowledge/agents/wire-all-three-agent-interfaces.md index d3ce4b2..87e859f 100644 --- a/community/knowledge/agents/wire-all-three-agent-interfaces.md +++ b/community/knowledge/agents/wire-all-three-agent-interfaces.md @@ -17,13 +17,13 @@ An AL agent type is registered by extending `Agent Metadata Provider`. The platf On the enum value, set `Implementation` for all three interfaces, each pointing at a dedicated codeunit. Keep factory (create, defaults, first-time setup), metadata (setup page, summary, annotations), and task execution (message analysis, intervention suggestions) in separate objects. -See sample: `wire-all-three-agent-interfaces.good.al`. +See sample: [`wire-all-three-agent-interfaces.good.al`](wire-all-three-agent-interfaces.good.al). ## Anti Pattern An `Agent Metadata Provider` value with no `Implementation`, only one interface mapped, or all three interfaces pointing at one catch-all codeunit that cannot satisfy the contracts. Detection signal: enumextension of `Agent Metadata Provider` whose value does not list `IAgentFactory`, `IAgentMetadata`, and `IAgentTaskExecution`. -See sample: `wire-all-three-agent-interfaces.bad.al`. +See sample: [`wire-all-three-agent-interfaces.bad.al`](wire-all-three-agent-interfaces.bad.al). ## See also diff --git a/community/skills/review/al-agents-review.md b/community/skills/review/al-agents-review.md index 4bc2a6b..80dadb0 100644 --- a/community/skills/review/al-agents-review.md +++ b/community/skills/review/al-agents-review.md @@ -4,7 +4,7 @@ id: al-agents-review version: 1 title: AL agents review description: Reviews AL source changes against agent guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -18,7 +18,10 @@ Reviews AL source changes against the `agents` knowledge domain in BCQuality and Agent findings apply to AL files that implement or invoke Agent SDK surfaces, including agent interfaces, setup, creation, task execution, capability registration, profiles, access controls, instructions, and session-bound subscribers. Return `not-applicable` when the diff contains no AL changes or no Agent SDK implementation or usage. -An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces one JSON document conforming to the DO output contract. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or +`folder-path`. For a folder, review all relevant source below it under DO's +current-state input semantics. The skill produces one JSON document +conforming to the DO output contract. ## Source diff --git a/custom/README.md b/custom/README.md index 28d7aa9..2719df8 100644 --- a/custom/README.md +++ b/custom/README.md @@ -12,6 +12,17 @@ custom/ ## How to use -Fork or clone BCQuality into your own repository and add your content here. Knowledge files in `/custom/knowledge/` follow the same frontmatter schema and section requirements as every other layer. Action skills in `/custom/skills/` follow the Action Skill template defined in `/skills/`. +Use a fork or organization-controlled copy of BCQuality, not the upstream +repository or your AL app's source folder. Confirm `git remote get-url origin` +points at your repository before adding custom content. Upstream does not +accept custom rules. -When agents consume BCQuality, the custom layer is loaded alongside Microsoft and Community — your overrides apply automatically. +Follow [Customizing BCQuality](../docs/customizing-bcquality.md) for a worked +rule, plugin configuration, installing your fork, and keeping it up to date. +Adding a rule here does not update an existing upstream plugin installation; +your host must consume your copy. + +Knowledge files follow [READ](../skills/read.md) and action skills follow +[DO](../skills/do.md). With the Custom layer enabled, applicable custom +knowledge overrides contradictory Community or Microsoft guidance. The report +records the displaced article; non-conflicting guidance remains additive. diff --git a/docs/README.md b/docs/README.md index 466b98e..1d7248b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,32 @@ -# Documentation +# BCQuality documentation -- [How agents consume BCQuality](agent-consumption.md) explains the operational - flow from Entry dispatch through structured findings and integration. -- [Build a lightweight standalone review runner](standalone-runner.md) explains - one concrete walk-up skill flow and how an external runner can add model - selection, concurrency, retries, and telemetry without moving orchestration - into BCQuality. +**New to BCQuality? Start with the [quick start](../README.md#quick-start).** +Install the plugin, discover its skills, and try an app review. No knowledge +of BCQuality's internal protocol is needed. + +## Partner guides + +| Goal | Guide | +| --- | --- | +| Review an app, file, changes, or branch | [Using BCQuality](using-bcquality.md) | +| Understand a report and its limitations | [Reading your results](using-bcquality.md#reading-your-results) | +| Find a particular rule or example | [Knowledge by domain](using-bcquality.md#knowledge-by-domain) | +| Fix setup problems or report an incorrect finding | [Troubleshooting and support](troubleshooting.md) | +| Select layers, add company rules, or maintain a fork | [Customizing BCQuality](customizing-bcquality.md) | +| Add or improve shared knowledge | [Contributing](contributing.md) | + +## Integration and technical reference + +These pages are for people building integrations or maintaining skills, not +prerequisites for using the plugin. + +| Reference | Purpose | +| --- | --- | +| [How agents consume BCQuality](agent-consumption.md) | Architecture, repository structure, routing, and delivery of findings. | +| [Standalone runner](standalone-runner.md) | Optional model selection, scheduling, retries, and telemetry. | +| [Global skills](../skills/README.md) | Host adapters versus internal protocol files. | +| [Entry](../skills/entry.md) | Task context and skill dispatch. | +| [READ](../skills/read.md) | Knowledge schema, applicability, and precedence. | +| [DO](../skills/do.md) | Action-skill format and structured output contract. | +| [WRITE](../skills/write.md) | Knowledge-authoring rules. | +| [Review evaluation](../evaluation/README.md) | Sample conventions, fixture preparation, and scoring. | diff --git a/docs/agent-consumption.md b/docs/agent-consumption.md index 2cb3761..1bf4284 100644 --- a/docs/agent-consumption.md +++ b/docs/agent-consumption.md @@ -5,13 +5,15 @@ supplied by a host or orchestrator. This document explains the end-to-end flow so that skill authors, orchestrator maintainers, and contributors share one mental model. -For the high-level framing and repo structure, start with the -[README](../README.md). This document is the operational view. +[Documentation](README.md) | [Partner quick start](../README.md#quick-start) | [Runner contract](standalone-runner.md) + +This is the operational reference for integration authors. Partners using +the installed plugin do not need to implement this flow themselves. ## The actors - **Orchestrator** — the tool that triggers work. Lives *outside* BCQuality. Knows *when* to run something, not *what* to run. -- **Agent** — an LLM-driven process spawned by the orchestrator. The agent has no built-in knowledge of BC or of BCQuality's conventions. It knows how to read instructions and call tools. +- **Agent** — an LLM-driven process supplied by the host. It brings its own coding knowledge and tools; BCQuality adds curated guidance and execution contracts. - **BCQuality repo** — two kinds of content: - **Global skills** in `/skills/` — the `entry.md` entry-point skill plus the READ · DO · WRITE contracts that govern the rest of the repo. - **Layer content** in `/microsoft/`, `/community/`, and `/custom/` — knowledge files and action skills grouped by authority. @@ -20,6 +22,25 @@ When BCQuality is installed as a standalone plugin, it additionally exposes `skills/al-code-review/SKILL.md`. This is a host-format adapter, not another action skill: it creates the task context and enters the same flow at Entry. +## Repository structure + +| Path | Purpose | +| --- | --- | +| `skills/entry.md` | Routes a task to action skills. | +| `skills/read.md`, `skills/do.md`, `skills/write.md` | Stable knowledge, action-skill, and authoring contracts. | +| `skills/al-code-review/SKILL.md` | Host-format plugin adapter. | +| `/knowledge//` | Atomic articles and optional sibling samples. | +| `/skills/` | Layer-owned action skills. | +| `docs/` | Partner guides and integration references. | +| `evaluation/` | Neutral review fixtures and scoring contract. | +| `tools/` | Knowledge-index and evaluation tooling. | +| `.github/` | Validation and repository workflows. | + +Layers are `microsoft`, `community`, and `custom`; Custom is a template for +consumer forks. An action skill either evaluates knowledge directly (a leaf) +or composes declared leaves (a super-skill). See [global skills](../skills/README.md) +for the distinction between host-native packaging and these internal formats. + ## The flow ```mermaid @@ -70,7 +91,17 @@ At this point the agent reads READ and DO on demand — it needs READ to interpr Discovering candidates at the Source step naively means opening every file under a domain folder just to read its frontmatter `keywords` — on a large corpus that is hundreds of file reads per review. To avoid this, BCQuality maintains a **knowledge index**: a single artifact (`knowledge-index.json`) that lists every article surviving the consumer's layer/allow-deny filtering and carries, per article, the exact inputs the Source/Worklist steps consume — `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint. -The index is **owned and produced by BCQuality**, not by each consumer: its generator (`tools/Build-KnowledgeIndex.ps1`) ships here, next to the skills and knowledge it derives from, so the index schema stays in lockstep with the Source contract and every consumer gets the same faithful index for free instead of re-implementing the parser. The consuming orchestrator does **not** build or invoke the index — it only prunes its clone to policy as it already does. The index is then (re)generated by BCQuality itself: **Entry's preparation step runs `Build-KnowledgeIndex.ps1` over the live, already-pruned clone** at the start of every run (see `skills/entry.md`), and BCQuality CI (`.github/workflows/knowledge-index.yml`) validates that the generator is healthy and deterministic. Building over the *pruned* clone — rather than shipping a committed full-corpus index that consumers trust — keeps the index exact for any consumer policy: it can never list an article the consumer denied, so policy-excluded rules cannot leak into discovery. +The index is **owned and produced by BCQuality**, not reimplemented by each +consumer. Its generator, `tools/Build-KnowledgeIndex.ps1`, ships here alongside +the content. Entry ensures the index reflects the live tree before routing +and regenerates it when absent or not known to be current. BCQuality CI +validates that the generator is healthy and deterministic. + +Consumers with allow/deny policy must prune their content copy **before** +Entry runs. Building over that pruned tree prevents removed articles from +entering discovery. A standalone plugin normally ships the whole tree: +`enabled-layers` filters discovery but does not remove files or enforce a +security boundary. See [layer selection](customizing-bcquality.md#select-layers-or-disable-a-review). The index changes only *how candidates are discovered*, never *which are selected*. The Worklist predicate is unchanged — `keywords` still drive selection — and the agent still opens each worklisted article **in full** to read its `## Best Practice` / `## Anti Pattern` rule bodies; the index is discovery metadata only and never substitutes for the article body. When no index is present, skills fall back to path-based discovery (collect by domain folder), so review still works. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..b4cbb0b --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,137 @@ +# Contributing to BCQuality + +[Documentation](README.md) | [Knowledge by domain](using-bcquality.md#knowledge-by-domain) | [Authoring reference](../skills/write.md) + +Partners are welcome to contribute shared knowledge, examples, skills, and +documentation. To report an incorrect finding without preparing a change, +use the [support guide](troubleshooting.md#reporting-a-problem). + +## What belongs here + +BCQuality is a remedial knowledge base. A knowledge file exists because a +capable LLM **would get something wrong, or miss something, without it**, not +simply because the topic is important. Apply this admission test: + +> If this file did not exist, would a modern LLM reviewing or generating BC +> code make a mistake this file would have prevented? + +Good candidates encode a BC-specific mechanic that models get wrong, a +version-dependent behavior, or a misleading interpretation of an analyzer +rule. For example: + +- [SetLoadFields and filters can be called in either order](../microsoft/knowledge/performance/use-setloadfields-for-partial-records.md): their relative order does not change the projection. This prevents an incorrect performance finding. +- [Boolean page record triggers default to true](../microsoft/knowledge/error-handling/page-boolean-triggers-default-to-true.md): omitting an explicit `exit(true)` is not itself a defect. +- [Page fields can inherit captions](../microsoft/knowledge/style/caption-required-on-page-fields.md): an omitted page-level property is not sufficient evidence that a caption is missing. + +Generic advice such as "use HTTPS," "do not hardcode secrets," or "keep +transactions short" does not earn a separate knowledge file merely by being +sound advice. Negative clarifications that prevent false positives are as +valuable as rules that catch defects. + +**Skills hold discovery and execution mechanics; knowledge files hold BC +facts.** Correct or extend a knowledge article when a BC fact is missing or +wrong. Do not hide that fact in a skill's instructions. A genuine routing, +input, or output-contract problem belongs in the skill instead. + +## Choose the right destination + +| Change | Destination | +| --- | --- | +| Knowledge in a Microsoft-owned review domain | `microsoft/knowledge//` | +| Knowledge accompanying a Community-owned skill | `community/knowledge//` | +| Company-specific policy or an override | `custom/` in your own fork; never an upstream contribution | +| Partner instructions or how-to guidance | `docs/`, linked from the documentation index | + +Layer ownership follows the skill and domain, **not your employer**. For +example, a partner's performance clarification belongs beside the Microsoft +performance skill's corpus. Do not use Community as a staging area for an +already Microsoft-owned domain. A split may exist briefly during promotion, +but the skill and its canonical corpus should move together. + +Upstream automatically closes PRs adding custom content. Follow +[Customizing BCQuality](customizing-bcquality.md) for organization-only rules. +Do not introduce a new shared domain without the action skill that consumes +it and the matching evaluation samples. + +## Author a knowledge article + +Read [READ](../skills/read.md) for the schema and +[WRITE](../skills/write.md) for the authoring rules. Use an existing article +in the same domain as a starting point, then remove unrelated guidance. + +Every article has six required frontmatter fields: `bc-version`, `domain`, +`keywords`, `technologies`, `countries`, and `application-area`. +`domain` must match its containing directory. Keep one concern per file, +ideally under 50 lines and no more than 100. + +`Description` is required. Put recommendations in `Best Practice` and mistakes +to catch in `Anti Pattern`; those are the normative sections. Explain +legitimate exceptions so a reviewer does not turn a useful rule into a false +positive. Code fences are not allowed in knowledge articles. + +### Sources and examples + +When adding or changing a platform claim, link the authoritative source that +supports it, preferably the specific Microsoft Learn API/property page or a +public source definition. State version constraints when they matter. Avoid +"upstream guidance says" without a link. If the source is unavailable or the +guidance is organization policy or empirical observation, say so explicitly +rather than presenting it as an official platform guarantee. + +Place source links in a short `References` section or beside the relevant +claim. References do not replace the rule: keep all load-bearing guidance in +the normative sections. This adds traceability without adding frontmatter +fields or changing the schema. + +Put demonstration code in sibling files: + +```text +.md +.good.al +.bad.al +``` + +Reference each sample with a clickable link whose label retains the filename, +for example `` [`.good.al`](.good.al) `` with your actual slug. +One or both samples are optional for an individual article; every review +domain must have at least one complete good/bad pair for evaluation. Samples +are self-contained demonstrations, not copied Base Application source and +not a deployable or compiled application. + +## Before opening a PR + +From your BCQuality checkout, use the existing validators. The Python +validator needs Python and PyYAML; the fixture harness needs PowerShell 7. +If PyYAML is not installed in your development environment, install it with +`python -m pip install pyyaml`. + +```powershell +python .github\scripts\validate_frontmatter.py --root . +pwsh .\tools\Test-ReviewFixtures.ps1 -Root . +``` + +The first command checks schema, sections, naming, sample references, and +skill registration. The second checks that every review leaf has a valid +positive/clean sample pair. Neither proves a model will find every defect. +See [evaluation](../evaluation/README.md) for optional model-based scoring. + +In the PR description, explain the mistake being prevented, supporting +evidence, applicable BC versions, and why the chosen domain owns it. For a +false positive, include the valid pattern and the incorrect finding being +prevented. Check that links and samples open from the rendered article. + +Schema and stable protocol changes require approval from both maintainers. +Avoid repeating schema or contract definitions in new guides: link the +canonical READ, DO, WRITE, or Entry section instead. + +## Content releases + +Maintainers cut content releases on demand, roughly monthly, using the +`Release version` workflow on `main`. It tags the selected commit as +`v{major}.{minor}`; it does not update the plugin manifest. + +Use a minor bump for normal content updates and a major bump for breaking +changes. The minor is a monotonic counter: it increments across releases and +does **not** reset on a major bump. See +[updates and versions](customizing-bcquality.md#updates-and-versions) for the +separate plugin, content, and skill version identifiers. diff --git a/docs/customizing-bcquality.md b/docs/customizing-bcquality.md new file mode 100644 index 0000000..d3d9148 --- /dev/null +++ b/docs/customizing-bcquality.md @@ -0,0 +1,173 @@ +# Customizing BCQuality + +[Documentation](README.md) | [Using BCQuality](using-bcquality.md) | [Contributing](contributing.md) + +**No customization is required to get started.** Use the upstream plugin +unless you need a different review selection or organization-specific rules. +Model choice, concurrency, retries, and billing belong to your host, not +BCQuality. A [standalone runner](standalone-runner.md) is an advanced option. + +## Select layers or disable a review + +The standalone adapter reads these environment variables from the process +that starts your host: + +| Variable | Default | Meaning | +| --- | --- | --- | +| `BCQUALITY_ENABLED_LAYERS` | `microsoft,community,custom` | Comma-separated layer names to discover. | +| `BCQUALITY_DISABLED_SKILLS` | None | Comma-separated **BCQuality repo-relative skill paths** to exclude, not display names or knowledge-article paths. | + +For example, in PowerShell, enable only Microsoft knowledge and omit the +dedicated style review: + +```powershell +$env:BCQUALITY_ENABLED_LAYERS = "microsoft" +$env:BCQUALITY_DISABLED_SKILLS = "microsoft/skills/review/al-style-review.md" +copilot +``` + +Set the variables **before** starting a new session. They apply to that +terminal and its child processes; use your host's environment configuration +if it starts elsewhere. Review selection is not a guarantee that another +domain or the agent will never mention a related concern. + +To return to defaults, remove those variables from the environment before +starting the host again (or use a fresh terminal if you only set them there). +Do not use an empty comma-separated value as a substitute for the default. + +All layers are enabled by default. Where relevant articles have overlapping +applicability and **contradictory guidance**, precedence is: + +**Custom > Community > Microsoft.** + +Otherwise the layers are additive. A matching filename alone does not suppress +an article; the [READ contract](../skills/read.md#layer-precedence) governs +knowledge conflicts. Review reports record displaced knowledge in `suppressed`. + +Layer selection is **not an access-control boundary**. A plugin installation +still contains excluded layers on disk. An integration requiring genuine +exclusion must remove denied files from its own content copy before the agent +reads it; the [adapter](../skills/al-code-review/SKILL.md#layer-selection-is-not-a-deny-mechanism) +explains this distinction. + +## Add an organization-specific rule + +Keep custom content in a fork or organization-controlled copy of BCQuality, +not in your AL app's `custom` folder and not in the installed plugin cache. +Editing the cache is not durable across updates. + +1. Fork BCQuality into a repository your organization controls, or create an + organization-controlled copy if a public fork is unsuitable for your policy. +2. Clone that repository and run `git remote get-url origin`. Confirm it is + your repository, **not** `microsoft/BCQuality`. +3. Add the article under `custom/knowledge//`, using the + [knowledge format](../skills/read.md). Keep your company's content out of + upstream pull requests. + +For example, suppose your company deliberately names one page "ACME Inventory +Workbench" while showing stockkeeping units, and already makes the row type +clear in its UI. You want a narrow exception to the shared page-naming rule. +Create `custom/knowledge/style/page-name-must-match-source-table.md` in your +copy with this content: + +```markdown +--- +bc-version: [all] +domain: style +keywords: [page-name, source-table, inventory, workbench] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Allow the ACME Inventory Workbench task name + +## Description + +Our approved page "ACME Inventory Workbench" shows stockkeeping units. Its UI +identifies the row type explicitly; its task-oriented name is company policy. + +## Best Practice + +Do not report that page solely because its name differs from its source-table +entity. Keep the shared naming guidance for other pages. + +## Anti Pattern + +Renaming the approved page solely to repeat the source-table entity, or +applying this exception to an unrelated page. +``` + +This is an **illustrative company policy**, not a new Microsoft recommendation. +Choose your actual domain, applicability, and policy; do not broaden an +exception merely to silence a valid defect. The shared rule is +[page-name-must-match-source-table.md](../microsoft/knowledge/style/page-name-must-match-source-table.md). +Guidance in `Best Practice` and `Anti Pattern` drives conflict resolution, so +do not put the exception only in a non-normative notes section. + +Follow the [contribution checks](contributing.md#before-opening-a-pr) locally, +then commit your change in your repository. A new knowledge domain also needs +an action skill that discovers it; adding an arbitrary folder does not create +a review. + +## Use your fork + +Adding custom content does not change the upstream plugin you already +installed. Point the host at your copy. + +For a pushed fork, replace `YOUR-ORG` with its owner. These commands replace +the upstream installation, since both manifests use the name `bcquality`: + +```powershell +copilot plugin uninstall bcquality +copilot plugin install YOUR-ORG/BCQuality +copilot plugin list +``` + +For local development, install your copy's absolute path instead: + +```powershell +copilot plugin install "C:\Repos\CompanyBCQuality" +``` + +Direct local installs are cached by the CLI; reinstall that path after edits, +then start a new session. See the host's +[local-plugin instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/plugins-creating). +Do not assume the currently running session has reloaded the content. + +Confirm the plugin list points at the intended source and that the Custom +layer is enabled. Review a small example relevant to your rule. Ask the host +which custom article it read and inspect `suppressed` for an actual conflict. +The negative-rule example should produce no naming finding for the approved +page; do not add an information-only finding just to prove the article was +loaded. The absence of a finding alone does not prove your fork was used. + +An external runner should likewise read from your fork or local copy rather +than the upstream URL. It must still start at [Entry](../skills/entry.md). + +## Updates and versions + +| Identifier | What it identifies | +| --- | --- | +| Plugin `version` in `plugin.json` | The host-facing package version. It is separate from content-release tags. `copilot plugin list` shows the installed plugin; inspect the resolved source when reproducing a run. | +| Content tag such as `v1.6` | A release of the repository's knowledge and skills. Available tags are listed on [GitHub](https://github.com/microsoft/BCQuality/tags). | +| Skill `version` in frontmatter | That skill's contract version, carried in reports. It does not identify the complete knowledge snapshot. | +| Git commit SHA | The exact repository snapshot. Record this for reproducibility when using a checkout. | + +For the upstream plugin, run `copilot plugin update bcquality`, then start a +new session. The unpinned installation command does not promise a particular +content-release tag. For a fork, updating the plugin reads your fork; it does +not merge upstream changes into it. + +To maintain a fork, commit your custom work first, add an `upstream` remote +pointing to `https://github.com/microsoft/BCQuality.git` once, fetch upstream, +and merge the desired upstream branch or content tag. Resolve conflicts and +review the resulting policy before publishing or reinstalling your fork. +Do not overwrite the fork wholesale with an upstream download. + +For repeatable CI or runner use, select a tag or commit in a dedicated clean +checkout and record `git rev-parse HEAD`. Upgrade deliberately, compare the +old and new content, and rerun representative reviews. To roll back, select +the previously recorded snapshot in that checkout and reinstall it if your +host caches local plugins. Retain organization-specific rules in the chosen +snapshot rather than reverting to an upstream-only tag. diff --git a/docs/standalone-runner.md b/docs/standalone-runner.md index 8bb1e67..31ab1dd 100644 --- a/docs/standalone-runner.md +++ b/docs/standalone-runner.md @@ -1,5 +1,7 @@ # Build a lightweight standalone review runner +[Documentation](README.md) | [Architecture](agent-consumption.md) + BCQuality provides review knowledge, routing, execution instructions, and structured output contracts. It intentionally does not choose models, schedule agents, retry failures, or collect usage telemetry. A standalone runner can add @@ -12,12 +14,9 @@ concurrency, or integration with another review surface. ## Keep BCQuality current -Install or update the plugin with GitHub Copilot CLI: - -```shell -copilot plugin install microsoft/BCQuality -copilot plugin update bcquality -``` +For plugin installation, use the [quick start](../README.md#quick-start). +For version identifiers, forks, and reproducible snapshots, see +[updates and versions](customizing-bcquality.md#updates-and-versions). A runner that reads BCQuality from a checkout should pin a commit or release and upgrade it deliberately. Do not copy knowledge files or action-skill prose @@ -30,16 +29,13 @@ diff, supply the app's root directory as `folder-path`. The review scope is every relevant file below that directory, including `app.json` and AL source. The folder does not need to be a Git repository. -With the standalone plugin installed, start a fresh Copilot session in the app -folder and ask: - -> Use the installed `al-code-review` skill to review the complete Business -> Central app in this folder. Execute every dispatched review domain and return -> the complete BCQuality findings report. - -The adapter maps this request to `folder-path`; Entry routes it to the broad -review super-skill. Because a folder is a current-state snapshot, the review -must not invent a previous app version when evaluating comparison-only rules. +The [app-review example](../README.md#example-review-a-complete-app-folder) +uses this input through the standalone adapter. Because a folder is a +current-state snapshot, the review must not invent a previous app version +when evaluating comparison-only rules. Entry can return more than one +top-level skill; preserve all reports, including separately dispatched +Community reviews, rather than assuming the Microsoft coordinator is the +only result. ## Minimal runner flow diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..bdfcd72 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,59 @@ +# Troubleshooting and support + +[Documentation](README.md) | [Quick start](../README.md#quick-start) | [Using BCQuality](using-bcquality.md) + +## Setup and skill discovery + +Run terminal commands outside the interactive Copilot prompt unless they +start with `/`. + +| Symptom | What to do | +| --- | --- | +| `copilot` is not recognized | [Install Copilot CLI](https://docs.github.com/en/copilot/get-started/cli-quickstart), then open a new terminal. Installing Copilot Chat in an editor is not the same step. | +| The CLI has no `plugin` command | Update Copilot CLI using its installation method. Confirm `copilot plugin --help` works. | +| Sign-in, entitlement, or organization-policy error | Start `copilot`, use `/login`, and confirm your account is allowed to use Copilot CLI. Ask your administrator about organization restrictions; BCQuality cannot override them. | +| Plugin installation cannot reach the repository | Confirm access to `https://github.com/microsoft/BCQuality` and follow your organization's proxy/network guidance. Do not disable certificate checks. | +| The plugin installed, but the skill is missing | Run `copilot plugin list` in the terminal. Enable it with `copilot plugin enable bcquality` if disabled, then start a new session. In the session, use `/skills list` and look for `al-code-review`. | +| The agent performs a generic review | Name the **installed `al-code-review` skill** explicitly, as in the quick start. Ask which skill and BCQuality source it used. Another plugin or host may expose a similarly named operation. | +| Installation works in the terminal, but not in the editor | Plugin discovery is host-specific. Follow the editor's installation instructions; a CLI installation is not proof that another host loaded the plugin. | +| `pwsh` is missing, or index generation fails | The index is an accelerator, not required knowledge. The review can fall back to discovery from folders. For faster discovery, install [PowerShell 7](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell), or resolve the reported filesystem error. | +| An update or local edit is not visible | Run `copilot plugin update bcquality` for a repository-installed plugin, then start a fresh session. For a directly installed local folder, reinstall that folder to refresh the cached copy; see [customizing](customizing-bcquality.md#use-your-fork). | + +## Review results + +| Symptom | What to do | +| --- | --- | +| `partial`, a timeout, or an unfinished review | Read `outcome-reason` and domain reports. Retry the incomplete scope in a fresh session, use smaller app folders or a focused review, or select a host/model with sufficient capacity. Keep the limited scope visible; do not relabel it a complete app review. | +| `failed` | Resolve the stated problem, such as inaccessible input, a failed invocation, or an unverifiable reference, before using that report. A failed domain's findings are not reliable. | +| `no-match` or `not-applicable` | Confirm you supplied AL source, the intended folder/file/diff, and an appropriate goal. Check [disabled skills and layers](customizing-bcquality.md#select-layers-or-disable-a-review). | +| `no-knowledge` | Check the target BC version, selected domain, enabled layers, and whether the relevant knowledge files are present. No applicable rules is different from no defects. | +| `completed` with no findings | This can be a valid clean result for the selected scope. Confirm the intended files and domain reports are included. If you have a concrete missed defect, report it with a minimal example. | +| JSON rather than a readable summary | JSON is the shared output format. Ask the host to summarize the existing reports, preserving outcomes, locations, severity, confidence, and references. | +| A surprising finding | Open its guidance and samples, inspect surrounding code, and confirm version/localization assumptions. Ask the agent to explain the evidence; do not apply a suggestion solely because it has high confidence. | +| The Agents domain is absent | Agents is a separate Community review, not a child of the Microsoft broad review. Explicitly request an Agent SDK review and confirm the Community layer is enabled. | +| A missing base branch or unavailable source definition | Supply the real baseline or dependency definition. Without it, do not accept claims that rely on invented history or assumed dependency behavior. | +| Slow or expensive review | A broad review makes separate passes over multiple domains. Verify index generation succeeded, use a focused task when appropriate, and inspect usage in your host. BCQuality does not choose models, promise runtimes, or meter charges. | + +## Reporting a problem + +For incorrect BC guidance, missed findings, documentation gaps, or skill +behavior, [search existing issues](https://github.com/microsoft/BCQuality/issues) +and [open a BCQuality issue](https://github.com/microsoft/BCQuality/issues/new/choose) +if needed. You do not have to author a knowledge file before asking for help. +Host installation, authentication, billing, or policy problems belong with the +host's support channel or your organization administrator. + +Include: + +- The host and version, selected model if known, and BCQuality source/version + or commit. See [version identifiers](customizing-bcquality.md#updates-and-versions). +- The prompt, scope (folder/file/diff and comparison base), target BC version, + and relevant layer/skill settings. +- Expected versus actual behavior, the outcome/reason, and the exact rule + reference for a disputed finding. +- A **minimal, sanitized** AL example or report excerpt that reproduces the + problem. Remove secrets, customer data, and proprietary content you cannot share. + +For security vulnerabilities, follow [SECURITY.md](../SECURITY.md) instead of +opening a public issue. To contribute a correction yourself, follow the +[contribution guide](contributing.md). diff --git a/docs/using-bcquality.md b/docs/using-bcquality.md new file mode 100644 index 0000000..bb1d955 --- /dev/null +++ b/docs/using-bcquality.md @@ -0,0 +1,190 @@ +# Using BCQuality + +[Documentation](README.md) | [Quick start](../README.md#quick-start) | [Troubleshooting](troubleshooting.md) + +BCQuality supplies knowledge and reusable skills to your AI host. The plugin +currently exposes `al-code-review`; the examples below use that skill. The +host supplies authentication, model access, tools, permissions, and rendering. +Installing BCQuality does not install a Business Central extension or an agent. + +## Hosts and prerequisites + +The [quick start](../README.md#quick-start) documents GitHub Copilot CLI. Use a +current CLI release with plugin support and sign in to an account allowed to +use it. In an interactive CLI session, `/skills list` should include +`al-code-review`; in the terminal, `copilot plugin list` should include +`bcquality`. + +Do not assume a CLI installation also installs the plugin into VS Code, +another editor, or another agent host. Follow that host's plugin instructions +and confirm it discovers `skills/al-code-review/SKILL.md`. Hosts without +compatible plugin discovery need an [integration](agent-consumption.md). + +Source review needs access to your files, not a running BC environment. +Include `app.json` and any relevant surrounding source. Dependency symbols or +a historical baseline may be needed to substantiate particular findings; a +review must not invent missing definitions or an earlier version of your app. +PowerShell 7 (`pwsh`) accelerates discovery by generating the knowledge index. +Without it, folder-based discovery is available and may take longer. + +## Common review requests + +Start a new host session after installing or updating the plugin. Use the +skill name explicitly and say what is in scope. Replace example paths and +branch names with ones in your project. + +| Task | Example prompt | +| --- | --- | +| Complete app | Use the installed al-code-review skill to review the complete Business Central app in this folder without changing my source files. Return the complete BCQuality findings report. | +| One file | Use the installed al-code-review skill to review `src\CustomerMgt.Codeunit.al` without changing it. Return the complete BCQuality findings report. | +| Uncommitted changes | Use the installed al-code-review skill to review my staged and unstaged tracked changes against HEAD, without changing files. Identify any untracked AL files not included in that diff. | +| Branch changes | Use the installed al-code-review skill to review changes on this branch since its merge base with `origin/main`. Exclude uncommitted changes and do not edit files. | +| Focused review | Use the installed al-code-review skill to review performance in the app in this folder, without changing files. Return the complete performance findings report. | +| Agent SDK code | Use the installed al-code-review skill to review Agent SDK implementation and usage in this app folder, without changing files. Return the complete Agents findings report. | + +For Git comparisons, the named base ref must exist locally. If it is missing, +fetch the intended branch first. A PR review also requires the host to have +the PR's changes and repository access; installing the plugin does not +automatically connect it to your PR workflow. + +A complete-folder review considers relevant files recursively, not just +modified files. Start in a single app's root for the clearest scope. For a +repository containing several apps, name each app folder and review them +separately when their target versions or dependencies differ. + +If known, add the target BC major version and localization to the request. +Do not use your extension's own `version` as the BC version. Missing +applicability context can reduce a finding's confidence or leave a rule out. + +## Reading your results + +The skill returns structured reports. A host may render them as text, a table, +or annotations, or show the JSON directly. You can ask the host to explain the +returned report without rerunning the review or changing files. + +For example, a performance report could contain this finding: + +| Field | Illustrative value | +| --- | --- | +| Outcome | `completed` | +| Location | `src\CustomerExport.Codeunit.al`, line 42 | +| Severity / confidence | `major` / `high` | +| Finding | A country filter is evaluated inside the customer loop, so rows that will be discarded are still read. Apply the filter before iterating. | +| Guidance | [Apply filters before iterating](../microsoft/knowledge/performance/apply-filters-before-iterating.md), with linked good/bad samples. | + +This illustrates a report, not a guaranteed finding or host screen. Read the +referenced article and the surrounding source before accepting a fix. + +### Outcomes + +| Outcome | Meaning and action | +| --- | --- | +| `completed` | The selected review finished. An empty `findings` list means it found nothing to flag in that scope, not that the app is certified defect-free. | +| `not-applicable` | The review did not apply to the supplied input. It is not a clean-review result. | +| `no-knowledge` | No applicable knowledge was available. Check scope, target context, and enabled layers. | +| `partial` | Some work did not finish. Read `outcome-reason` and the individual reports; do not treat the result as a full pass. | +| `failed` | No reliable result from that review. Resolve the reported error before relying on it. | +| `no-match` | Routing found no suitable skill. Check the request, input type, and disabled skills. | + +A broad review includes individual domain reports in `sub-results`. Separately +dispatched skills return separate reports, so do not mistake the first report +for the whole run. Coverage counts describe selected knowledge items evaluated, +not a percentage of all possible defects or every rule in the repository. + +For example, this completed **domain** report evaluated one selected knowledge +item and found nothing to flag: + +```json +{ + "skill": { "id": "al-performance-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 1, "items-evaluated": 1 } + }, + "findings": [], + "suppressed": [] +} +``` + +This is not evidence that other domains ran; their reports must also be present +when requested. + +### Severity, confidence, and references + +| Severity | Meaning | +| --- | --- | +| `blocker` | A platform-level guarantee is violated; the work cannot proceed as-is. | +| `major` | A significant defect that should be addressed before merge. | +| `minor` | A quality concern; advisory rather than a gate. | +| `info` | Concrete context or an observation, not an instruction to change code. | + +Confidence (`high`, `medium`, or `low`) describes the strength of the evidence, +not the impact. Missing version or localization context must be disclosed in +the finding when conditionally applicable knowledge is used. + +Knowledge-backed findings link to the articles that informed them. Findings +from the agent's own reasoning have no knowledge reference (`references: []`); +these are advisory, with severity capped at `minor` and confidence at `medium`. +The display domain `Agents` means Agent SDK guidance; it is different from +`Agent`, the label for the broad coordinator's own cross-cutting observations. +Any `suppressed` entries explain knowledge overridden by configuration or +layer precedence. + +A report can include a code suggestion. **A suggestion is not an applied +change.** Review the explanation first, then request any edits explicitly, +for example: "Apply only the filter fix at line 42 from this report." Continue +using your normal compilation, analyzer, test, and human-review workflow. + +## Coverage and limits + +The Microsoft broad review composes the 16 Microsoft domains listed below. +The Community Agents review is a separate skill selected by the request, not +a nested part of that coordinator. All current review leaves accept app +folders, files, and diffs; request an Agent SDK review explicitly when that +coverage matters and look for its separate report. + +Available knowledge is **not** a promise that every rule will run. Selection +depends on the task, target context, enabled layers, and source evidence. +A whole-folder review is a current-state snapshot: detecting a published API +removal or another comparison-only regression requires an actual baseline. +The corpus is technical AL guidance, not exhaustive functional validation or +AppSource certification. + +### Knowledge by domain + +Each article describes one concern. Where samples exist, use its linked +`.good.al` and `.bad.al` files. Samples are demonstrations, not a deployable app. + +| Domain | Browse knowledge | +| --- | --- | +| Agent SDK | [Agents (Community)](../community/knowledge/agents/) | +| AppSource | [AppSource](../microsoft/knowledge/appsource/) | +| Compatibility | [Breaking changes](../microsoft/knowledge/breaking-changes/) | +| Data modeling | [Data modeling](../microsoft/knowledge/data-modeling/) | +| Error handling | [Error handling](../microsoft/knowledge/error-handling/) | +| Events | [Events](../microsoft/knowledge/events/) | +| Interfaces | [Interfaces](../microsoft/knowledge/interfaces/) | +| Performance | [Performance](../microsoft/knowledge/performance/) | +| Privacy | [Privacy](../microsoft/knowledge/privacy/) | +| Query objects | [Query](../microsoft/knowledge/query/) | +| Security | [Security](../microsoft/knowledge/security/) | +| Style | [Style](../microsoft/knowledge/style/) | +| Telemetry | [Telemetry](../microsoft/knowledge/telemetry/) | +| Testing | [Testing](../microsoft/knowledge/testing/) | +| User interface | [UI](../microsoft/knowledge/ui/) | +| Upgrades | [Upgrade](../microsoft/knowledge/upgrade/) | +| APIs and web services | [Web services](../microsoft/knowledge/web-services/) | + +## Permissions and data + +The review instructions produce findings, not source edits or deployment. +The host still controls tool permissions: keep approval prompts enabled and +do not grant blanket write or deployment access just to run a review. +BCQuality may write its generated `knowledge-index.json` into its own installed +directory; that is separate from your app's source. + +BCQuality is content, not an AI service. Your chosen host and model determine +where source code is processed, what usage is billed, and which data policies +apply. Review those policies before supplying proprietary or customer code. +Installing the plugin does not make an online host run locally or offline. diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md index a3542a1..a53e584 100644 --- a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md +++ b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md @@ -21,10 +21,10 @@ This rule scopes to Marketplace ISV extensions, which is what AppSourceCop valid Own objects use the registered affix (for example `ABC Loyalty Tier`) or, when targeting BC23 or later, a qualifying namespace. Every field or action added to a standard object remains individually affixed (for example `Loyalty Points ABC` on a `Customer` tableextension). -See sample: `object-affixes-prevent-collisions.good.al`. +See sample: [`object-affixes-prevent-collisions.good.al`](object-affixes-prevent-collisions.good.al). ## Anti Pattern An owned object with neither a qualifying namespace nor an affix, an unaffixed extension member, or the common half-measure where the extension object carries the affix but a field it adds to a standard table does not. AS0011 flags the missing collision protection and the field can still collide with another app. -See sample: `object-affixes-prevent-collisions.bad.al`. +See sample: [`object-affixes-prevent-collisions.bad.al`](object-affixes-prevent-collisions.bad.al). diff --git a/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md b/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md index ca89003..1b81fb4 100644 --- a/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md +++ b/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md @@ -17,10 +17,10 @@ An AppSource app must provide permission sets that let assigned users complete t Trace every setup page, normal page, report, codeunit, and tabledata operation exposed by the app and cover it through assignable role permission sets composed from focused non-assignable sets. Validate setup and representative workflows as a user assigned only those app roles. Grant the minimum required operations; completeness is not a reason to use wildcards. -See sample: `permission-sets-cover-setup-and-usage-without-super.good.al`. +See sample: [`permission-sets-cover-setup-and-usage-without-super.good.al`](permission-sets-cover-setup-and-usage-without-super.good.al). ## Anti Pattern Shipping no permission set, omitting a tabledata or execute grant used by the app's own UI, or instructing users and validators to assign `SUPER` when setup fails. Do not flag a permission-set name that differs from the app name; no such naming requirement exists. -See sample: `permission-sets-cover-setup-and-usage-without-super.bad.al`. +See sample: [`permission-sets-cover-setup-and-usage-without-super.bad.al`](permission-sets-cover-setup-and-usage-without-super.bad.al). diff --git a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md index 8e46049..fef6d6c 100644 --- a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md +++ b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md @@ -19,10 +19,10 @@ The requirement comes from AppSourceCop rule AS0011, which only runs when the ap Choose one collision strategy for owned objects: a registered affix or a globally meaningful namespace with at least two levels. Regardless of that choice, apply the registered affix to every member added to a base or third-party object. Keep the affix configured for AppSourceCop so member validation remains deterministic. Do not raise a missing member affix against an app that does not enable AppSourceCop with a mandatory affix; there AS0011 never fires, and the app's namespace is not the reason — the absent configuration is. -See sample: `two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al`. +See sample: [`two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al`](two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al). ## Anti Pattern Using `namespace Contoso;` as though one level satisfied the AppSource alternative, or declaring `namespace Contoso.Rentals;` and then adding an unaffixed `Loyalty Points` field to `Customer` in an app that does configure a mandatory affix. The namespace distinguishes the extension's own objects; it cannot disambiguate members on Customer. The mirror-image mistake is reporting an unaffixed extension member in an app that enables no mandatory affix at all — AS0011 does not apply there, and the finding is a false positive. -See sample: `two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al`. +See sample: [`two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al`](two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al). diff --git a/microsoft/knowledge/breaking-changes/choose-access-modifiers-deliberately.md b/microsoft/knowledge/breaking-changes/choose-access-modifiers-deliberately.md index 835312d..6786631 100644 --- a/microsoft/knowledge/breaking-changes/choose-access-modifiers-deliberately.md +++ b/microsoft/knowledge/breaking-changes/choose-access-modifiers-deliberately.md @@ -17,10 +17,10 @@ Access is a decision about what you are willing to support forever. The moment a Start everything `local` or `internal` and promote a member to `public` only when you have decided to support it as a stable contract. Expose a small, intentional surface — the supported entry point — and keep validation, posting, and helper routines `internal` for in-app reuse or `local` when single-object. Do not drop `[Scope('OnPrem')]` without intent, since that too widens the contract. Every public member is a maintenance commitment; spend them deliberately. -See sample: `choose-access-modifiers-deliberately.good.al`. +See sample: [`choose-access-modifiers-deliberately.good.al`](choose-access-modifiers-deliberately.good.al). ## Anti Pattern Declaring every procedure `public` by default, so internal helpers like `ValidateOrder` and `PostOrder` become a de-facto API that consumers bind to and that can no longer be changed freely. Detection: an object where implementation-detail procedures carry no access modifier or are `public` without a reason to support them externally. Default them to `internal`/`local` and make only the intended entry point public. -See sample: `choose-access-modifiers-deliberately.bad.al`. +See sample: [`choose-access-modifiers-deliberately.bad.al`](choose-access-modifiers-deliberately.bad.al). diff --git a/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md b/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md index 35a342e..f7d05d5 100644 --- a/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md +++ b/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md @@ -17,10 +17,10 @@ Deleting or renaming a published procedure (or object) in a single release is a When a published procedure is superseded, keep it in place and mark it `[Obsolete('Use CalculateNetAmount instead.', '25.0')]`, where the message names the replacement and the tag records when the method became obsolete. Have the obsolete member forward to the new one so behavior is preserved during the window. Only after the deprecation window has elapsed should a later release delete the method. For an object or field, use `Pending` during the warning window and `Removed` afterward. -See sample: `deprecate-public-members-with-the-obsolete-lifecycle.good.al`. +See sample: [`deprecate-public-members-with-the-obsolete-lifecycle.good.al`](deprecate-public-members-with-the-obsolete-lifecycle.good.al). ## Anti Pattern Renaming or deleting the published `CalcNet` procedure in place — replacing it with `CalculateNetAmount` and nothing else — so consumers calling `CalcNet` break immediately with no deprecation notice. Detection: a previously shipped non-`local` procedure that vanished or was renamed between versions with no `[Obsolete]` marker left behind during a prior warning window. Do not suggest `ObsoleteState = Removed` for a method; that property belongs to supported object and element types. -See sample: `deprecate-public-members-with-the-obsolete-lifecycle.bad.al`. +See sample: [`deprecate-public-members-with-the-obsolete-lifecycle.bad.al`](deprecate-public-members-with-the-obsolete-lifecycle.bad.al). diff --git a/microsoft/knowledge/breaking-changes/do-not-change-published-procedure-signatures.md b/microsoft/knowledge/breaking-changes/do-not-change-published-procedure-signatures.md index 75fa6c1..5fa0ae5 100644 --- a/microsoft/knowledge/breaking-changes/do-not-change-published-procedure-signatures.md +++ b/microsoft/knowledge/breaking-changes/do-not-change-published-procedure-signatures.md @@ -19,10 +19,10 @@ This rule governs procedures that dependents *call*. An event publisher — a pr Treat a published signature as frozen. When new behavior needs more inputs, add a new procedure or overload alongside the original — for example a `CalculateDiscountWithRate(Amount; Rate)` next to the unchanged `CalculateDiscount(Amount)` — and let the old one delegate to the new one. Existing callers keep compiling; new callers opt into the richer entry point. Naming an unnamed return value is the one in-place change that is always safe. -See sample: `do-not-change-published-procedure-signatures.good.al`. +See sample: [`do-not-change-published-procedure-signatures.good.al`](do-not-change-published-procedure-signatures.good.al). ## Anti Pattern Editing the existing public procedure's parameter list — here, adding a `Rate` parameter to `CalculateDiscount` — so every dependent extension that called the old form fails to compile. Detection: a parameter added, removed, reordered, retyped, or flipped to/from `var`, or a changed return type, on any non-`local` procedure that already shipped. Add a new overload instead. Exclude event publishers whose only change is an added parameter: subscribers bind by parameter name, not position, so that edit is additive and reporting it here is a false positive. -See sample: `do-not-change-published-procedure-signatures.bad.al`. +See sample: [`do-not-change-published-procedure-signatures.bad.al`](do-not-change-published-procedure-signatures.bad.al). diff --git a/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md b/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md index bd32d2d..cb3f772 100644 --- a/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md +++ b/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md @@ -17,10 +17,10 @@ Every member you make publicly reachable becomes a contract you must keep — an Keep secrets in `internal` or `local` members, and prefer the `SecretText` type so the value cannot be read back or logged. Where callers genuinely need a credential, pass it inward (a setter) rather than handing it outward (a getter). Public API should return only non-sensitive data — a masked reference, a status, a business identifier — never the raw secret. Treat each public member as a lasting commitment and keep the security-sensitive surface as small as possible. -See sample: `do-not-expose-sensitive-data-through-public-api.good.al`. +See sample: [`do-not-expose-sensitive-data-through-public-api.good.al`](do-not-expose-sensitive-data-through-public-api.good.al). ## Anti Pattern A public `GetAccessToken()` that returns the raw token (or an event parameter carrying a credential to all subscribers), turning a secret into a de-facto public API any dependent can consume. Detection: a non-`local` procedure, event parameter, or global variable that surfaces a token, password, key, or other credential. Keep the secret internal and expose only non-sensitive data. -See sample: `do-not-expose-sensitive-data-through-public-api.bad.al`. +See sample: [`do-not-expose-sensitive-data-through-public-api.bad.al`](do-not-expose-sensitive-data-through-public-api.bad.al). diff --git a/microsoft/knowledge/breaking-changes/do-not-modify-code-already-marked-obsolete.md b/microsoft/knowledge/breaking-changes/do-not-modify-code-already-marked-obsolete.md index 781810b..4609ae3 100644 --- a/microsoft/knowledge/breaking-changes/do-not-modify-code-already-marked-obsolete.md +++ b/microsoft/knowledge/breaking-changes/do-not-modify-code-already-marked-obsolete.md @@ -17,10 +17,10 @@ A member carrying `[Obsolete]`, or wrapped in a `#if not CLEANxx` conditional-co Leave obsolete members exactly as they are and implement against the current, supported replacement. New logic — a surcharge calculation, an event publisher, a hook — belongs on the live API (`GetUnitPrice`), never inside the deprecated `GetPrice` or behind a `#if not CLEAN25` guard. If the replacement does not yet exist, create it as a first-class member and build there. The obsolete code should only shrink over time, not accrete new behavior. -See sample: `do-not-modify-code-already-marked-obsolete.good.al`. +See sample: [`do-not-modify-code-already-marked-obsolete.good.al`](do-not-modify-code-already-marked-obsolete.good.al). ## Anti Pattern Adding a surcharge calculation inside the `[Obsolete]` `GetPrice` procedure, or behind a `#if not CLEAN25` block, so the new behavior is wired to code that will be removed when `CLEAN25` is enabled. Detection: new statements, event declarations, or dependencies introduced inside an `[Obsolete]`-marked member or a `#if not CLEANxx` region. Move the logic onto the supported replacement instead. -See sample: `do-not-modify-code-already-marked-obsolete.bad.al`. +See sample: [`do-not-modify-code-already-marked-obsolete.bad.al`](do-not-modify-code-already-marked-obsolete.bad.al). diff --git a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md index fde0f54..6c345ee 100644 --- a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md +++ b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md @@ -17,10 +17,10 @@ AL resolves an object by namespace and name. Once an app ships and dependent ext Choose a globally meaningful namespace before first publication and keep it stable. Add new functional areas beneath that structure without moving existing published objects. If an identity must move, use the platform's supported move/obsoletion lifecycle rather than a source-only namespace rename. -See sample: `namespace-is-part-of-published-object-identity.good.al`. +See sample: [`namespace-is-part-of-published-object-identity.good.al`](namespace-is-part-of-published-object-identity.good.al). ## Anti Pattern Changing `namespace Contoso.Rentals;` to `namespace Contoso.RentalManagement;` as a cleanup while leaving the object name and ID untouched. Every dependent `using` directive and qualified reference targets the old identity and stops compiling. -See sample: `namespace-is-part-of-published-object-identity.bad.al`. +See sample: [`namespace-is-part-of-published-object-identity.bad.al`](namespace-is-part-of-published-object-identity.bad.al). diff --git a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md index 3ff6474..bf62fac 100644 --- a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md +++ b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md @@ -17,10 +17,10 @@ A shipped table field carries both a source-level contract and persisted data. R Keep the old field's ID, name, and type unchanged. Add the replacement as a separate field under an unused ID, then mark the old field `ObsoleteState = Pending` with an `ObsoleteReason` that names the replacement and an `ObsoleteTag` recording the obsoletion version. Keep the old field readable so an upgrade codeunit can copy its data during the deprecation window. Move it to `ObsoleteState = Removed` only in a later release, after the window has passed and data has migrated. -See sample: `obsolete-table-fields-instead-of-deleting-them.good.al`. +See sample: [`obsolete-table-fields-instead-of-deleting-them.good.al`](obsolete-table-fields-instead-of-deleting-them.good.al). ## Anti Pattern Renaming published `Email` to `Contact Email` with the same ID violates the compatibility contract and AS0005, even though the retained ID does not itself imply a fresh empty column. Deleting `Email` or changing its ID additionally risks losing its stored values. Detection: any previously shipped field whose name changes at the same ID, or whose original ID disappears without the unchanged field being retained as `Pending` and its data migrated to a separate replacement field. -See sample: `obsolete-table-fields-instead-of-deleting-them.bad.al`. +See sample: [`obsolete-table-fields-instead-of-deleting-them.bad.al`](obsolete-table-fields-instead-of-deleting-them.bad.al). diff --git a/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md index e324d45..698980f 100644 --- a/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md +++ b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md @@ -19,10 +19,10 @@ Putting the block check inside the master's own `OnInsert`/`OnModify` does nothi The referencing line validates `Master.TestField(Blocked, false)` in `OnValidate` of the reference field and re-checks before posting. The master table stays logic-free on `Blocked`. -See sample: `check-blocked-in-referencing-code-not-in-master.good.al`. +See sample: [`check-blocked-in-referencing-code-not-in-master.good.al`](check-blocked-in-referencing-code-not-in-master.good.al). ## Anti Pattern The block check sits in the master's own `OnModify`/`OnInsert` (so referencing and posting proceed unchecked), or there is no check at all on the referencing side. -See sample: `check-blocked-in-referencing-code-not-in-master.bad.al`. +See sample: [`check-blocked-in-referencing-code-not-in-master.bad.al`](check-blocked-in-referencing-code-not-in-master.bad.al). diff --git a/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md index f4c6a15..69476ff 100644 --- a/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md +++ b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md @@ -19,10 +19,10 @@ This is not an `Integer` `AutoIncrement` key, a GUID, or the `SystemId`. Those a `No.` `Code[20]` is the sole primary key; a non-editable `No. Series` `Code[20]` field records the source series. `OnInsert` checks `if "No." = ''`, reads the setup table, `TestField`s the configured series, stores it in `No. Series`, and assigns `No.` from the series. -See sample: `master-table-no-from-number-series-in-oninsert.good.al`. +See sample: [`master-table-no-from-number-series-in-oninsert.good.al`](master-table-no-from-number-series-in-oninsert.good.al). ## Anti Pattern An `Integer` `AutoIncrement` (or GUID / `SystemId`) primary key used as the business key, with no `OnInsert` number assignment. Records get an opaque identifier no user can reference, and the master no longer participates in the standard numbering and manual-entry behavior every other BC master follows. -See sample: `master-table-no-from-number-series-in-oninsert.bad.al`. +See sample: [`master-table-no-from-number-series-in-oninsert.bad.al`](master-table-no-from-number-series-in-oninsert.bad.al). diff --git a/microsoft/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md b/microsoft/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md index 82bf81d..5ded169 100644 --- a/microsoft/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md +++ b/microsoft/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md @@ -25,7 +25,7 @@ See also `validate-table-relation-false-suppresses-rename-propagation.md` for th The owning table implements `OnDelete` and deletes its dependents there, filtered on the foreign key. Declare `Permissions = tabledata = rd` on the owning table — granting delete rights only on the parent is a common miss that makes the trigger fail for a non-`SUPER` user. This mirrors the base application, where every header table deletes its own lines. -See sample: `owning-table-must-delete-dependents-in-ondelete.good.al`. +See sample: [`owning-table-must-delete-dependents-in-ondelete.good.al`](owning-table-must-delete-dependents-in-ondelete.good.al). ## Anti Pattern @@ -33,4 +33,4 @@ A parent table with dependent rows and no `OnDelete` trigger, where the dependen Detection signal: a table declares `TableRelation` to table X, and table X has no `OnDelete` trigger. Whether a delete path currently exists in the UI is irrelevant to the finding. -See sample: `owning-table-must-delete-dependents-in-ondelete.bad.al`. +See sample: [`owning-table-must-delete-dependents-in-ondelete.bad.al`](owning-table-must-delete-dependents-in-ondelete.bad.al). diff --git a/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md index dbc0a64..f8b2a0d 100644 --- a/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md +++ b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md @@ -19,10 +19,10 @@ The reason is a BC-specific trap: renaming a record changes its primary key and Both `OnModify` and `OnRename` set `"Last Date Modified" := Today();`, and the field is declared `Editable = false` so only the triggers maintain it. -See sample: `set-last-date-modified-in-onmodify-and-onrename.good.al`. +See sample: [`set-last-date-modified-in-onmodify-and-onrename.good.al`](set-last-date-modified-in-onmodify-and-onrename.good.al). ## Anti Pattern Only `OnModify` assigns `Last Date Modified`. After a rename the value is stale, and any process that trusts it to detect changes misses the record. -See sample: `set-last-date-modified-in-onmodify-and-onrename.bad.al`. +See sample: [`set-last-date-modified-in-onmodify-and-onrename.bad.al`](set-last-date-modified-in-onmodify-and-onrename.bad.al). diff --git a/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md index 774963f..0a7ac8b 100644 --- a/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md +++ b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md @@ -19,10 +19,10 @@ The setup **card** page enforces the singleton: `InsertAllowed = false` and `Del `Primary Key` `Code[10]` is the sole key; the setup is surfaced through a Card page with `InsertAllowed = false`, `DeleteAllowed = false`, and an open-time guard that inserts the blank row if it is missing. -See sample: `setup-table-is-a-singleton.good.al`. +See sample: [`setup-table-is-a-singleton.good.al`](setup-table-is-a-singleton.good.al). ## Anti Pattern An `Integer` / `AutoIncrement` key, a page that allows insert or delete, or a List page over the setup table. Any of these lets the table hold zero or many rows, so "the setup" becomes ambiguous and `Get()` may fail or read the wrong record. -See sample: `setup-table-is-a-singleton.bad.al`. +See sample: [`setup-table-is-a-singleton.bad.al`](setup-table-is-a-singleton.bad.al). diff --git a/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.md b/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.md index 10946a5..0921227 100644 --- a/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.md +++ b/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.md @@ -17,10 +17,10 @@ application-area: [all] When sharing media between different tables, iterate the source `MediaSet` and call `Target.MediaSetField.Insert(Source.MediaSetField.Item(Index))`, then modify the target record. Direct field assignment is safe only when source and target are the same record subtype and use the same field ID. This concern is about reference/delete integrity, not the separate performance cost of `ModifyAll` on tables with media fields. -See sample: `share-mediaset-items-with-insert-not-field-assignment.good.al`. +See sample: [`share-mediaset-items-with-insert-not-field-assignment.good.al`](share-mediaset-items-with-insert-not-field-assignment.good.al). ## Anti Pattern `Target.Picture := Source.Picture;` where the two variables refer to different table types or different media-field IDs. The code copies an opaque ID, but the platform does not know that two independent fields now share the media object. -See sample: `share-mediaset-items-with-insert-not-field-assignment.bad.al`. +See sample: [`share-mediaset-items-with-insert-not-field-assignment.bad.al`](share-mediaset-items-with-insert-not-field-assignment.bad.al). diff --git a/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.md b/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.md index 1908f82..84bdc0b 100644 --- a/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.md +++ b/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.md @@ -17,10 +17,10 @@ A `tableextension` can add to an existing `TableRelation`, but the combined rela When a relation is designed to follow an extensible enum, express the base cases as conditional branches and leave no unconditional catch-all ahead of future extension branches. An enum extension can then append a condition for its new value. When extending a field you do not own, inspect the original `TableRelation`; do not claim that an appended condition overrides an unconditional relation. -See sample: `table-relation-extensions-are-additive-and-top-down.good.al`. +See sample: [`table-relation-extensions-are-additive-and-top-down.good.al`](table-relation-extensions-are-additive-and-top-down.good.al). ## Anti Pattern A base field has an unconditional `TableRelation = Customer;` and a `tableextension` adds `if (Type = const(Resource)) Resource`. The original unconditional branch always wins, so the new enum value still validates and looks up against Customer. The concern is evaluation order, not `ValidateTableRelation`; free-form input is covered separately by security guidance. -See sample: `table-relation-extensions-are-additive-and-top-down.bad.al`. +See sample: [`table-relation-extensions-are-additive-and-top-down.bad.al`](table-relation-extensions-are-additive-and-top-down.bad.al). diff --git a/microsoft/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md b/microsoft/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md index 7d1ea77..7f288b2 100644 --- a/microsoft/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md +++ b/microsoft/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md @@ -17,10 +17,10 @@ application-area: [all] Use `TransferFields(Source)` only when every field the destination requires, including primary key fields, is guaranteed to share a matching field number and type with the source; this form defaults `InitPrimaryKeyFields` to `true`. Fields with no matching field number, and fields whose types differ across extensions, are skipped regardless of `SkipFieldsNotMatchingType` — that parameter only governs same-extension type mismatches. If the destination depends on a field that falls into either case, map and validate it explicitly in code rather than relying on `TransferFields` to catch the gap. Use `SkipFieldsNotMatchingType = true` only when skipping same-extension type mismatches is an intentional, documented part of the transfer contract. -See sample: `transferfields-skip-type-mismatch-can-drop-data.good.al`. +See sample: [`transferfields-skip-type-mismatch-can-drop-data.good.al`](transferfields-skip-type-mismatch-can-drop-data.good.al). ## Anti Pattern Using `TransferFields(Source, InitPrimaryKeyFields, true)` as a generic way to make two evolving table schemas transfer without errors, when the destination depends on every required source field being copied. A type change on either table can turn a previously transferred field into a silently skipped one without making the transfer itself fail. -See sample: `transferfields-skip-type-mismatch-can-drop-data.bad.al`. \ No newline at end of file +See sample: [`transferfields-skip-type-mismatch-can-drop-data.bad.al`](transferfields-skip-type-mismatch-can-drop-data.bad.al). \ No newline at end of file diff --git a/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md index b4d19b9..f80e35d 100644 --- a/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md +++ b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md @@ -19,10 +19,10 @@ LLMs reproduce the legacy `NoSeriesManagement` pattern because it dominates pre- `OnInsert` assigns the number with `NoSeries.GetNextNo("No. Series")` where `NoSeries` is `Codeunit "No. Series"`. The `No.` field's `OnValidate` guards manual entry by calling `NoSeries.IsManual(...)` (or `TestManual`) before clearing `No. Series`. -See sample: `use-no-series-codeunit-not-noseriesmanagement.good.al`. +See sample: [`use-no-series-codeunit-not-noseriesmanagement.good.al`](use-no-series-codeunit-not-noseriesmanagement.good.al). ## Anti Pattern `NoSeriesMgt.InitSeries(...)` for assignment and `NoSeriesMgt.TestManual(...)` for the manual check, where `NoSeriesMgt` is `Codeunit NoSeriesManagement`. Both are obsolete-pending and emit compiler warnings. -See sample: `use-no-series-codeunit-not-noseriesmanagement.bad.al`. +See sample: [`use-no-series-codeunit-not-noseriesmanagement.bad.al`](use-no-series-codeunit-not-noseriesmanagement.bad.al). diff --git a/microsoft/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md b/microsoft/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md index ddd4856..0131edb 100644 --- a/microsoft/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md +++ b/microsoft/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md @@ -27,7 +27,7 @@ See also `owning-table-must-delete-dependents-in-ondelete.md` for the delete hal Leave `ValidateTableRelation` at its default wherever the stored value must stay correct across a rename. When it must be disabled, or when the relationship cannot be expressed as a `TableRelation` at all, the table owning the referenced key carries an explicit `OnRename` that repoints the dependents itself. -See sample: `validate-table-relation-false-suppresses-rename-propagation.good.al`. +See sample: [`validate-table-relation-false-suppresses-rename-propagation.good.al`](validate-table-relation-false-suppresses-rename-propagation.good.al). ## Anti Pattern @@ -35,4 +35,4 @@ See sample: `validate-table-relation-false-suppresses-rename-propagation.good.al Detection signal: any `ValidateTableRelation = false` on a field that also declares a `TableRelation`. Ask what repoints the value when the target is renamed; if the answer is "the platform", the finding stands. -See sample: `validate-table-relation-false-suppresses-rename-propagation.bad.al`. +See sample: [`validate-table-relation-false-suppresses-rename-propagation.bad.al`](validate-table-relation-false-suppresses-rename-propagation.bad.al). diff --git a/microsoft/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md b/microsoft/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md index 52c06bd..8c90095 100644 --- a/microsoft/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md +++ b/microsoft/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md @@ -25,7 +25,7 @@ See also `validate-table-relation-false-suppresses-rename-propagation.md`, which Use `xRec` for the previous key in `OnRename`, and for the record being removed in `OnDelete`. In `OnModify`, obtain the before-image by re-reading the stored row rather than trusting `xRec`, so the logic behaves identically whether a page, a job queue or an API drove the write. -See sample: `xrec-is-a-before-image-only-in-some-triggers.good.al`. +See sample: [`xrec-is-a-before-image-only-in-some-triggers.good.al`](xrec-is-a-before-image-only-in-some-triggers.good.al). ## Anti Pattern @@ -33,4 +33,4 @@ Comparing `Rec` against `xRec` inside `OnModify` (or `OnInsert`) to detect a cha Detection signal: any read of `xRec` inside `OnModify` or `OnInsert`. Treat "but it works when I test it on the page" as confirmation of the defect rather than a refutation. -See sample: `xrec-is-a-before-image-only-in-some-triggers.bad.al`. +See sample: [`xrec-is-a-before-image-only-in-some-triggers.bad.al`](xrec-is-a-before-image-only-in-some-triggers.bad.al). diff --git a/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md b/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md index b464fec..cdb87b6 100644 --- a/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md +++ b/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md @@ -17,10 +17,10 @@ By default a procedure stops on the first `Error`, so a user fixing ten bad rows Mark the orchestrating procedure `[ErrorBehavior(ErrorBehavior::Collect)]` and run each item's validation so one failure doesn't abandon the rest — typically by calling the per-item routine through `Codeunit.Run`. When the run finishes, inspect `HasCollectedErrors()`, retrieve and clear the list with `GetCollectedErrors(true)`, and fail the operation with the collected messages. The sample intentionally produces a text aggregate and does not claim to retain record/field metadata in the final error. If that metadata is needed, map each `ErrorInfo` to a custom error UI before clearing, following the Microsoft Learn pattern. Do not replace validation failure with `Message`: clearing collected errors suppresses the platform failure, so the custom handler must still block the invalid operation. -See sample: `collect-validation-errors-with-errorbehavior.good.al`. +See sample: [`collect-validation-errors-with-errorbehavior.good.al`](collect-validation-errors-with-errorbehavior.good.al). ## Anti Pattern Three shapes signal trouble. Hand-rolled accumulation reimplements collection and prevents the handler from receiving individual `ErrorInfo` values. A `Collect` procedure that never handles the collection falls back to the concatenated platform dialog. Finally, code that calls parameterless `GetCollectedErrors()`, assumes it cleared the list, and only shows a `Message` can both leave the errors collected and allow invalid processing to continue. -See sample: `collect-validation-errors-with-errorbehavior.bad.al`. +See sample: [`collect-validation-errors-with-errorbehavior.bad.al`](collect-validation-errors-with-errorbehavior.bad.al). diff --git a/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md b/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md index 127fa50..ece3baf 100644 --- a/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md +++ b/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md @@ -17,10 +17,10 @@ application-area: [all] Reserve `ErrorType::Internal` for errors the user cannot act on: corrupted internal state, an unreachable branch, a contract a caller violated. Set a precise, detail-rich `Message` for telemetry, raise it via `Error(ErrorInfo)`, and let the platform show the user a generic dialog. Keep `ErrorType::Client` (or a plain `Error`) for failures the user is expected to read and resolve — validation messages, missing setup, business-rule violations. The test is simple: if the message only makes sense to a developer, mark it `Internal`. -See sample: `errortype-internal-vs-client-for-diagnostics.good.al`. +See sample: [`errortype-internal-vs-client-for-diagnostics.good.al`](errortype-internal-vs-client-for-diagnostics.good.al). ## Anti Pattern Raising an internal failure with a plain `Error('Unexpected state: ledger bucket %1 not initialized', BucketId)`. The user is shown a technical message they can do nothing about, and the signal is buried in a generic error rather than carried as structured telemetry detail. Detection: an `Error` whose wording targets a developer ("unexpected", "should not happen", raw internal identifiers) raised with default `Client` visibility instead of an `ErrorInfo` marked `ErrorType::Internal`. -See sample: `errortype-internal-vs-client-for-diagnostics.bad.al`. +See sample: [`errortype-internal-vs-client-for-diagnostics.bad.al`](errortype-internal-vs-client-for-diagnostics.bad.al). diff --git a/microsoft/knowledge/error-handling/fielderror-default-message-logic.md b/microsoft/knowledge/error-handling/fielderror-default-message-logic.md index c02bb4f..51c116a 100644 --- a/microsoft/knowledge/error-handling/fielderror-default-message-logic.md +++ b/microsoft/knowledge/error-handling/fielderror-default-message-logic.md @@ -14,9 +14,9 @@ application-area: [all] ## Best Practice For a plain required-field check, prefer `TestField`, which tests the condition and raises the error in one call. When the condition is non-trivial and has already been evaluated, call `FieldError(FieldNo)` with no message to get the localized default (`must have a value`, `is not valid`, etc.), or pass a short lowercase predicate such as `FieldError(FieldNo, 'must be a positive number')`. Start the custom text with a lowercase letter so it reads as one sentence with the auto-inserted caption, and use a field-number reference (or the field token) rather than a hard-coded field name so captions and translations stay correct. Let the framework supply the caption, value, table, and key context for you. -See sample: `fielderror-default-message-logic.good.al`. +See sample: [`fielderror-default-message-logic.good.al`](fielderror-default-message-logic.good.al). ## Anti Pattern Re-testing a condition you already evaluated, or passing a fully formed sentence like `'The Amount field must be positive.'` to `FieldError`. The result reads as `Amount The Amount field must be positive. in Gen. Journal Line ...` — capital letter mid-sentence, caption and value repeated, and a stray trailing clause. Reviewer signals: a `FieldError` argument that names the field, restates the current value, starts with a capital letter, or ends with a period. Each is a sign the author treated `FieldError` like `Error` instead of as a predicate slotted into framework-generated context. -See sample: `fielderror-default-message-logic.bad.al`. \ No newline at end of file +See sample: [`fielderror-default-message-logic.bad.al`](fielderror-default-message-logic.bad.al). \ No newline at end of file diff --git a/microsoft/knowledge/error-handling/fielderror-vs-testfield.md b/microsoft/knowledge/error-handling/fielderror-vs-testfield.md index 9b58b32..845a0db 100644 --- a/microsoft/knowledge/error-handling/fielderror-vs-testfield.md +++ b/microsoft/knowledge/error-handling/fielderror-vs-testfield.md @@ -16,9 +16,9 @@ Use `TestField` when the condition is a simple presence-or-equality check on a s A page action's `OnAction` trigger is a different case: a page action is only invocable through its own UI control, so when the action's `Enabled` property is already bound to the same condition the trigger would otherwise `TestField`, the control cannot be clicked while the field is blank and the field can never reach the trigger empty. Adding a `TestField` there is redundant defensive code, not a missing check — flag it only when the trigger can run through a path `Enabled` does not cover (a shared procedure, an API, or a condition broader than what gates the action). -See sample: `fielderror-vs-testfield.good.al`. +See sample: [`fielderror-vs-testfield.good.al`](fielderror-vs-testfield.good.al). ## Anti Pattern Calling `FieldError` to "test" a field — placing it on a path that is reached unconditionally and expecting it to validate — terminates execution every time because `FieldError` never evaluates a condition. The inverse smell is reaching for `TestField` when the rule needs a tailored message, then bolting a vague generic string onto a check that cannot express the real business reason. A reviewer can spot the first by a `FieldError` that is not guarded by a preceding `if`, and the second by a `TestField` whose intent comment describes a condition more complex than presence or equality. -See sample: `fielderror-vs-testfield.bad.al`. +See sample: [`fielderror-vs-testfield.bad.al`](fielderror-vs-testfield.bad.al). diff --git a/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md index 52e3e40..902528b 100644 --- a/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md +++ b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md @@ -17,13 +17,13 @@ A procedure marked `[TryFunction]` catches errors only when the caller uses its Consume the result directly: assign it to a Boolean or use the call in an `if` condition. Handle `false` immediately while the last-error state still describes that failure. -See sample: `ignored-tryfunction-return-disables-try-semantics.good.al`. +See sample: [`ignored-tryfunction-return-disables-try-semantics.good.al`](ignored-tryfunction-return-disables-try-semantics.good.al). ## Anti Pattern Calling a `[TryFunction]` procedure as a standalone statement and assuming the attribute suppresses its errors. The call has ordinary error semantics because its Boolean result is ignored. -See sample: `ignored-tryfunction-return-disables-try-semantics.bad.al`. +See sample: [`ignored-tryfunction-return-disables-try-semantics.bad.al`](ignored-tryfunction-return-disables-try-semantics.bad.al). ## See also diff --git a/microsoft/knowledge/error-handling/prefer-errorinfo-for-actionable-errors.md b/microsoft/knowledge/error-handling/prefer-errorinfo-for-actionable-errors.md index f774cc8..082e118 100644 --- a/microsoft/knowledge/error-handling/prefer-errorinfo-for-actionable-errors.md +++ b/microsoft/knowledge/error-handling/prefer-errorinfo-for-actionable-errors.md @@ -17,10 +17,10 @@ A plain `Error('text')` ends the operation with a dead-end dialog: the user read Build an `ErrorInfo`, set `Title`, `Message`, and `DetailedMessage`, then attach the action that matches the situation. For a Fix-it, call `AddAction(Caption, Codeunit::Handler, 'MethodName')` where the handler method (which receives the `ErrorInfo`) applies the known-good value; phrase the caption as "Set value to …". For a Show-it, set `PageNo := Page::"…"`, set `RecordId` so navigation opens the right record, and call `AddNavigationAction('Show …')`. Raise it with `Error(ErrorInfo)`. Reserve recommended actions for cases where the solution is genuinely known and the user has permission to apply it. -See sample: `prefer-errorinfo-for-actionable-errors.good.al`. +See sample: [`prefer-errorinfo-for-actionable-errors.good.al`](prefer-errorinfo-for-actionable-errors.good.al). ## Anti Pattern Surfacing a recoverable validation failure with `Error('You cannot invoice more than %1 units.', MaxQty)` and nothing else. The user is blocked with no offered remedy even though the code knows the maximum and could set it. The detection signal: an `Error` call in a validation or posting path whose message names a specific correct value or a specific related page, with no surrounding `ErrorInfo`, `AddAction`, or `AddNavigationAction`. Replace it with an `ErrorInfo` that carries the corresponding Fix-it or Show-it action. -See sample: `prefer-errorinfo-for-actionable-errors.bad.al`. +See sample: [`prefer-errorinfo-for-actionable-errors.bad.al`](prefer-errorinfo-for-actionable-errors.bad.al). diff --git a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md index b05b020..418a50f 100644 --- a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md +++ b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md @@ -17,10 +17,10 @@ Event subscribers bind publisher parameters by name and can omit parameters they Add a parameter directly only when the shipped event publisher is `local` or `internal`. Place it where the signature is clearest; existing subscribers continue binding the parameters they name. For a public event, keep the original publisher unchanged and introduce a new event with the expanded contract. -See sample: `add-new-event-parameters-at-the-end.good.al`. +See sample: [`add-new-event-parameters-at-the-end.good.al`](add-new-event-parameters-at-the-end.good.al). ## Anti Pattern Appending a parameter to a public event and assuming its position makes the change compatible. Existing external callers still lack the new required argument. Conversely, do not flag a parameter inserted among existing parameters on a `local` or `internal` Business or Integration event merely because it was not appended. -See sample: `add-new-event-parameters-at-the-end.bad.al`. +See sample: [`add-new-event-parameters-at-the-end.bad.al`](add-new-event-parameters-at-the-end.bad.al). diff --git a/microsoft/knowledge/events/avoid-loosely-typed-event-parameters.md b/microsoft/knowledge/events/avoid-loosely-typed-event-parameters.md index 6c1e004..894b1ad 100644 --- a/microsoft/knowledge/events/avoid-loosely-typed-event-parameters.md +++ b/microsoft/knowledge/events/avoid-loosely-typed-event-parameters.md @@ -17,10 +17,10 @@ Passing `RecordRef` or `xRec` as event parameters weakens the contract. A `Recor Give events concrete record types and explicit values, such as `(SalesLine: Record "Sales Line"; PreviousQuantity: Decimal)`, instead of a `RecordRef` or an `xRec` parameter. Subscribers then get type safety, field access, and an unambiguous contract. -See sample: `avoid-loosely-typed-event-parameters.good.al`. +See sample: [`avoid-loosely-typed-event-parameters.good.al`](avoid-loosely-typed-event-parameters.good.al). ## Anti Pattern Event parameters typed as `RecordRef` (no table type) or an `xRec`-style "previous record" (ambiguous, possibly stale) without strong justification. Detection: an event signature containing a `RecordRef` parameter, or a passed-through `xRec` record, where a concrete typed record and explicit values would serve. -See sample: `avoid-loosely-typed-event-parameters.bad.al`. +See sample: [`avoid-loosely-typed-event-parameters.bad.al`](avoid-loosely-typed-event-parameters.bad.al). diff --git a/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.md b/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.md index 7e791fe..80b3b34 100644 --- a/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.md +++ b/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.md @@ -17,10 +17,10 @@ A `TryFunction` catches all errors — including errors thrown by event subscrib Raise the integration event before entering the TryFunction scope. The event and its subscribers execute outside the error boundary, so subscriber errors propagate normally to the caller. Move only the operation that genuinely needs error isolation (such as an HTTP call or a posting step) inside the TryFunction. -See sample: `avoid-raising-events-inside-try-functions.good.al`. +See sample: [`avoid-raising-events-inside-try-functions.good.al`](avoid-raising-events-inside-try-functions.good.al). ## Anti Pattern Raising an integration event inside a TryFunction body. Subscriber failures are caught and discarded by the TryFunction. The subscriber contract — that a subscriber can signal failure to the caller — is silently broken. -See sample: `avoid-raising-events-inside-try-functions.bad.al`. +See sample: [`avoid-raising-events-inside-try-functions.bad.al`](avoid-raising-events-inside-try-functions.bad.al). diff --git a/microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md b/microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md index 9fe7312..68c0dc3 100644 --- a/microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md +++ b/microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md @@ -17,10 +17,10 @@ An `[EventSubscriber]` codeunit is static by default (`EventSubscriberInstance = Use a static subscriber for behaviour that genuinely applies all the time. For anything scoped, mark the codeunit `EventSubscriberInstance = Manual`, call `BindSubscription(SubscriberInstance)` at the start of the scope and `UnbindSubscription(SubscriberInstance)` at the end. A manual subscriber held only in a local variable unbinds automatically when that variable leaves scope, which suits test setup/teardown; a binding you intend to outlive a single call must be unbound explicitly. Keep subscriber methods `local` per CodeCop AA0207. -See sample: `choose-static-vs-manual-subscribers-deliberately.good.al`. +See sample: [`choose-static-vs-manual-subscribers-deliberately.good.al`](choose-static-vs-manual-subscribers-deliberately.good.al). ## Anti Pattern Two shapes. First, a static subscriber used for behaviour that should be scoped — an always-on side effect (sending mail, writing extra records) that now fires for every event in every session and test with no way to disable it. Second, a manual subscriber that is bound with `BindSubscription` and never unbound: when the instance is held beyond the intended scope (for example on a `SingleInstance` codeunit), the binding leaks for the whole session and later unrelated operations keep hitting it. Detection: scoped side effects on a static subscriber, or a `BindSubscription` call with no matching `UnbindSubscription` and no scope that releases the instance. -See sample: `choose-static-vs-manual-subscribers-deliberately.bad.al`. +See sample: [`choose-static-vs-manual-subscribers-deliberately.bad.al`](choose-static-vs-manual-subscribers-deliberately.bad.al). diff --git a/microsoft/knowledge/events/declare-event-publishers-local-or-internal.md b/microsoft/knowledge/events/declare-event-publishers-local-or-internal.md index 9d097b4..ceca2ca 100644 --- a/microsoft/knowledge/events/declare-event-publishers-local-or-internal.md +++ b/microsoft/knowledge/events/declare-event-publishers-local-or-internal.md @@ -29,7 +29,7 @@ Give an event publisher the narrowest access modifier that still lets the code o Subscribers are unaffected by any of these choices. A non-public publisher also keeps the freedom to add a parameter later, which a public publisher gives up — see `add-new-event-parameters-at-the-end`. -See sample: `declare-event-publishers-local-or-internal.good.al`. +See sample: [`declare-event-publishers-local-or-internal.good.al`](declare-event-publishers-local-or-internal.good.al). ## Anti Pattern @@ -39,4 +39,4 @@ Detection: an `[IntegrationEvent]` or `[BusinessEvent]` publisher that is public The mirror-image anti-pattern belongs to the reviewer, human or agent: recommending that a publisher be made public so extensions can subscribe, or reporting a `local`/`internal` publisher as unreachable dead code. Both readings mistake raising for subscribing. Neither should be raised as a finding. -See sample: `declare-event-publishers-local-or-internal.bad.al`. +See sample: [`declare-event-publishers-local-or-internal.bad.al`](declare-event-publishers-local-or-internal.bad.al). diff --git a/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.md b/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.md index bf563b4..df0c5e2 100644 --- a/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.md +++ b/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.md @@ -17,10 +17,10 @@ Adding a `var IsHandled: Boolean` parameter to an event that already shipped wit Keep the existing event as-is and add a separate `OnBeforeX(…; var IsHandled: Boolean)` before the logic you want to make overridable. Two events with distinct, stable contracts are safer than one event whose meaning and signature were changed under its subscribers. -See sample: `do-not-add-ishandled-to-an-existing-event.good.al`. +See sample: [`do-not-add-ishandled-to-an-existing-event.good.al`](do-not-add-ishandled-to-an-existing-event.good.al). ## Anti Pattern Mutating a shipped event — for example adding `var IsHandled` to `OnAfterCalculateTotal` — to retrofit override behaviour, which overloads the event's meaning and undermines existing subscribers. Detection: an `IsHandled` parameter added to a pre-existing event signature rather than introduced through a new dedicated `OnBefore` publisher. -See sample: `do-not-add-ishandled-to-an-existing-event.bad.al`. +See sample: [`do-not-add-ishandled-to-an-existing-event.bad.al`](do-not-add-ishandled-to-an-existing-event.bad.al). diff --git a/microsoft/knowledge/events/do-not-bypass-critical-operations-with-ishandled.md b/microsoft/knowledge/events/do-not-bypass-critical-operations-with-ishandled.md index e6941ce..7eff65f 100644 --- a/microsoft/knowledge/events/do-not-bypass-critical-operations-with-ishandled.md +++ b/microsoft/knowledge/events/do-not-bypass-critical-operations-with-ishandled.md @@ -17,10 +17,10 @@ The IsHandled override pattern lets a subscriber skip the guarded code entirely. Scope IsHandled to a safe value-calculation block and run the critical operations unconditionally afterwards; or expose a positive `OnAfter…` event for subscribers to adjust results, rather than a bypass around the commit. -See sample: `do-not-bypass-critical-operations-with-ishandled.good.al`. +See sample: [`do-not-bypass-critical-operations-with-ishandled.good.al`](do-not-bypass-critical-operations-with-ishandled.good.al). ## Anti Pattern An `OnBefore…` IsHandled guard wrapping a posting or ledger routine — `if IsHandled then exit;` around the code that creates ledger entries and updates document status — letting subscribers skip the commit. Detection: an `if IsHandled then exit;` whose skipped body performs posting, ledger writes, number-series consumption, or integrity and permission validation. -See sample: `do-not-bypass-critical-operations-with-ishandled.bad.al`. +See sample: [`do-not-bypass-critical-operations-with-ishandled.bad.al`](do-not-bypass-critical-operations-with-ishandled.bad.al). diff --git a/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md index 98e0ca7..f613f3a 100644 --- a/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md +++ b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md @@ -17,10 +17,10 @@ application-area: [all] Keep every available attribute argument exactly as shipped. If new subscribers need different sender/global exposure, publish a new event with the desired flags. Apply the same rule to `Isolated` only on BC20 or later, where that argument exists. Raise both events while the original contract is supported, and choose preferred flags only when designing a new event. -See sample: `do-not-change-shipped-event-attribute-flags.good.al`. +See sample: [`do-not-change-shipped-event-attribute-flags.good.al`](do-not-change-shipped-event-attribute-flags.good.al). ## Anti Pattern Changing a shipped event's `IncludeSender` or `GlobalVarAccess` to modernize its design, including replacing `IncludeSender` with an explicit parameter. On BC20 or later, adding, removing, or toggling `Isolated` is equally contract-significant. Even a change that leaves old subscribers compiling can alter observable execution or exposure; version the event instead. -See sample: `do-not-change-shipped-event-attribute-flags.bad.al`. +See sample: [`do-not-change-shipped-event-attribute-flags.bad.al`](do-not-change-shipped-event-attribute-flags.bad.al). diff --git a/microsoft/knowledge/events/do-not-publish-events-inside-loops.md b/microsoft/knowledge/events/do-not-publish-events-inside-loops.md index badbf28..ecebd19 100644 --- a/microsoft/knowledge/events/do-not-publish-events-inside-loops.md +++ b/microsoft/knowledge/events/do-not-publish-events-inside-loops.md @@ -17,10 +17,10 @@ Raising an event on every iteration of a loop multiplies the cost of every subsc Raise `OnBeforeProcessLines` before the loop and `OnAfterProcessLines` after it, outside the `repeat … until`, so each subscriber runs once per batch rather than once per row. Give those events the record or filters they need to operate on the whole set. -See sample: `do-not-publish-events-inside-loops.good.al`. +See sample: [`do-not-publish-events-inside-loops.good.al`](do-not-publish-events-inside-loops.good.al). ## Anti Pattern An event raised inside the loop body, fired once per iteration, so subscriber cost scales with the row count and large batches slow down or time out. Detection: an `OnBefore…`/`OnAfter…`/`On…` raise located between `repeat` and `until` in a record loop. -See sample: `do-not-publish-events-inside-loops.bad.al`. +See sample: [`do-not-publish-events-inside-loops.bad.al`](do-not-publish-events-inside-loops.bad.al). diff --git a/microsoft/knowledge/events/expose-process-context-via-manually-bound-flag.md b/microsoft/knowledge/events/expose-process-context-via-manually-bound-flag.md index a8e72be..802b7b0 100644 --- a/microsoft/knowledge/events/expose-process-context-via-manually-bound-flag.md +++ b/microsoft/knowledge/events/expose-process-context-via-manually-bound-flag.md @@ -25,7 +25,7 @@ Publish the context as a query and let the binding itself be the state. One proc Bind a fresh instance per run rather than reusing one: the platform refuses to bind the same instance twice but accepts several instances of the same codeunit, so nesting and re-entrancy need no counter. The binding is session-scoped, so work the process starts in another session — a background session, a page background task, a job queue entry — cannot see it; pass the context explicitly there. -See sample: `expose-process-context-via-manually-bound-flag.good.al`. +See sample: [`expose-process-context-via-manually-bound-flag.good.al`](expose-process-context-via-manually-bound-flag.good.al). ## Anti Pattern @@ -37,4 +37,4 @@ Second, the context kept private: the driving app arranges its own marker — ty The mirror-image anti-pattern belongs to the reviewer: flagging the `BindSubscription` here as a leaked binding because no `UnbindSubscription` follows it. Scope release is the mechanism, not an omission — see `microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md`, whose leak case is an instance parked on a `SingleInstance` global that never leaves scope. -See sample: `expose-process-context-via-manually-bound-flag.bad.al`. +See sample: [`expose-process-context-via-manually-bound-flag.bad.al`](expose-process-context-via-manually-bound-flag.bad.al). diff --git a/microsoft/knowledge/events/name-event-parameters-without-abbreviations.md b/microsoft/knowledge/events/name-event-parameters-without-abbreviations.md index 539dd06..9985ae2 100644 --- a/microsoft/knowledge/events/name-event-parameters-without-abbreviations.md +++ b/microsoft/knowledge/events/name-event-parameters-without-abbreviations.md @@ -17,10 +17,10 @@ Event parameter names are part of the public contract a subscriber codes against Use full, unabbreviated names: `(SalesHeader: Record "Sales Header"; DocumentNo: Code[20]; Amount: Decimal)`. Record parameters mirror the table name without spaces, and value parameters read as whole words so the contract is unambiguous. -See sample: `name-event-parameters-without-abbreviations.good.al`. +See sample: [`name-event-parameters-without-abbreviations.good.al`](name-event-parameters-without-abbreviations.good.al). ## Anti Pattern Abbreviated parameter names (`SalesHdr`, `DocNo`, `Amt`) that obscure meaning and vary across publishers, so subscribers must guess what each one holds. Detection: event parameters whose names are truncated forms of the table name or contracted words rather than the full term. -See sample: `name-event-parameters-without-abbreviations.bad.al`. +See sample: [`name-event-parameters-without-abbreviations.bad.al`](name-event-parameters-without-abbreviations.bad.al). diff --git a/microsoft/knowledge/events/name-events-by-publisher-position.md b/microsoft/knowledge/events/name-events-by-publisher-position.md index cd8ac65..fc94098 100644 --- a/microsoft/knowledge/events/name-events-by-publisher-position.md +++ b/microsoft/knowledge/events/name-events-by-publisher-position.md @@ -17,10 +17,10 @@ An event name should tell a subscriber where in the publisher the event fires. T Name by position: `OnBeforePostSalesLine` and `OnAfterPostSalesLine` at the routine boundaries, and `OnPostSalesLineOnAfterCalcAmounts` for an event raised partway through `PostSalesLine` after an amount calculation. The name alone then tells a subscriber both the host routine and the exact point it runs. -See sample: `name-events-by-publisher-position.good.al`. +See sample: [`name-events-by-publisher-position.good.al`](name-events-by-publisher-position.good.al). ## Anti Pattern Ad-hoc event names that omit the host routine or the before/after position (`MyCustomSalesEvent`, `BeforePost`, `SalesLineEvent`), leaving subscribers unable to tell when the event fires relative to the publisher's logic. Detection: publisher names that do not follow the `OnBefore`/`OnAfter` or `OnOnBefore`/`OnAfter` patterns. -See sample: `name-events-by-publisher-position.bad.al`. +See sample: [`name-events-by-publisher-position.bad.al`](name-events-by-publisher-position.bad.al). diff --git a/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.md b/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.md index 3136023..d2e272b 100644 --- a/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.md +++ b/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.md @@ -17,10 +17,10 @@ Before adding a publisher, check whether an event already fires at that point in When the data you need is already exposed at an existing event, subscribe to it. When the event lacks a parameter, extend that event by appending the parameter at the end — one publisher, one raise — rather than adding a second event beside it. -See sample: `prefer-reusing-or-extending-existing-events.good.al`. +See sample: [`prefer-reusing-or-extending-existing-events.good.al`](prefer-reusing-or-extending-existing-events.good.al). ## Anti Pattern Adding a second event raise immediately after an existing one, or creating `OnBeforeProcessOrderWithCustomer` next to `OnBeforeProcessOrder` just to add a single parameter. Detection: two consecutive `OnBefore…`/`OnAfter…` raises with no logic between them, or near-duplicate event names differing only by a parameter-describing suffix. -See sample: `prefer-reusing-or-extending-existing-events.bad.al`. +See sample: [`prefer-reusing-or-extending-existing-events.bad.al`](prefer-reusing-or-extending-existing-events.bad.al). diff --git a/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md b/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md index 35d75dc..2af8daa 100644 --- a/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md +++ b/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md @@ -17,10 +17,10 @@ When designing a new publisher, setting `IncludeSender` to `true` on `[Integrati For a new event, declare the publisher `[IntegrationEvent(false, false)]` with an explicit `Sender: Codeunit "…"` parameter and raise it with `this`, for example `OnBeforeProcessOrder(OrderNo, this);`. Subscribers then receive a typed sender they can call directly. -See sample: `prefer-this-over-includesender-in-codeunit-events.good.al`. +See sample: [`prefer-this-over-includesender-in-codeunit-events.good.al`](prefer-this-over-includesender-in-codeunit-events.good.al). ## Anti Pattern Designing a new codeunit event with `[IntegrationEvent(true, …)]` solely to hand subscribers the publisher instance, where `this` could be passed explicitly as a typed parameter. Do not apply this rule by mutating a shipped event's attribute flags. -See sample: `prefer-this-over-includesender-in-codeunit-events.bad.al`. +See sample: [`prefer-this-over-includesender-in-codeunit-events.bad.al`](prefer-this-over-includesender-in-codeunit-events.bad.al). diff --git a/microsoft/knowledge/events/prefix-temporary-record-event-parameters-with-temp.md b/microsoft/knowledge/events/prefix-temporary-record-event-parameters-with-temp.md index 0a952a5..08370bf 100644 --- a/microsoft/knowledge/events/prefix-temporary-record-event-parameters-with-temp.md +++ b/microsoft/knowledge/events/prefix-temporary-record-event-parameters-with-temp.md @@ -17,10 +17,10 @@ When a record passed to an event is a temporary record — an in-memory buffer n Name temporary record parameters with a `Temp` prefix, for example `var TempSalesLineBuffer: Record "Sales Line" temporary`, so every subscriber sees immediately that the record is an in-memory buffer and treats writes accordingly. -See sample: `prefix-temporary-record-event-parameters-with-temp.good.al`. +See sample: [`prefix-temporary-record-event-parameters-with-temp.good.al`](prefix-temporary-record-event-parameters-with-temp.good.al). ## Anti Pattern A temporary record parameter named without the `Temp` prefix (`var SalesLineBuffer: Record "Sales Line" temporary`), so subscribers cannot tell the record is non-persistent and may rely on writes that are silently discarded. Detection: an event parameter declared `temporary` whose name does not start with `Temp`. -See sample: `prefix-temporary-record-event-parameters-with-temp.bad.al`. +See sample: [`prefix-temporary-record-event-parameters-with-temp.bad.al`](prefix-temporary-record-event-parameters-with-temp.bad.al). diff --git a/microsoft/knowledge/events/preserve-onafter-execution-when-ishandled-skips-the-body.md b/microsoft/knowledge/events/preserve-onafter-execution-when-ishandled-skips-the-body.md index 4ff958c..10b7fd1 100644 --- a/microsoft/knowledge/events/preserve-onafter-execution-when-ishandled-skips-the-body.md +++ b/microsoft/knowledge/events/preserve-onafter-execution-when-ishandled-skips-the-body.md @@ -17,10 +17,10 @@ A routine that exposes both an `OnBefore…` event (with `var IsHandled`) and a Wrap only the default work in `if not IsHandled then begin … end;` and keep the `OnAfterX(…)` raise after that block, outside the guard, so it always fires regardless of whether a subscriber handled the OnBefore. This keeps the override seam and the after-notification independent, which is what subscribers expect. -See sample: `preserve-onafter-execution-when-ishandled-skips-the-body.good.al`. +See sample: [`preserve-onafter-execution-when-ishandled-skips-the-body.good.al`](preserve-onafter-execution-when-ishandled-skips-the-body.good.al). ## Anti Pattern Guarding with `if IsHandled then exit;` and placing the `OnAfterX` raise later in the same routine, so handling the OnBefore short-circuits the whole procedure and the OnAfter event is skipped along with the body. Detection: an `if IsHandled then exit;` in a routine that also raises a paired `OnAfter…` event after that point. -See sample: `preserve-onafter-execution-when-ishandled-skips-the-body.bad.al`. +See sample: [`preserve-onafter-execution-when-ishandled-skips-the-body.bad.al`](preserve-onafter-execution-when-ishandled-skips-the-body.bad.al). diff --git a/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.md b/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.md index 30c94df..526f136 100644 --- a/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.md +++ b/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.md @@ -17,10 +17,10 @@ A key operation — a posting, release, or validation routine — becomes a hard Wrap the operation's core with events: raise `OnBeforeX(var Rec, var IsHandled)` before the default work and `OnAfterX(var Rec)` once it succeeds, at the natural boundaries of the routine. Declare each publisher `[IntegrationEvent(false, false)] local procedure` with an empty body and let the calling routine — never the publisher — own the logic. Pass records by `var` so subscribers can read and adjust them, and include the parameters a subscriber would need to act. This gives partners a stable seam without touching base code. -See sample: `publish-thin-onbefore-onafter-integration-events.good.al`. +See sample: [`publish-thin-onbefore-onafter-integration-events.good.al`](publish-thin-onbefore-onafter-integration-events.good.al). ## Anti Pattern Business logic placed inside an `[IntegrationEvent]` publisher method, so the "event" actually mutates state every time it is raised — defeating the hook and surprising every reader — or a core operation that exposes no extension points at all, forcing partners to overwrite or duplicate it. Detection: an `[IntegrationEvent]`/`[BusinessEvent]` method whose body contains statements rather than being empty, or a posting/validation routine with no surrounding `OnBefore`/`OnAfter` publishers. -See sample: `publish-thin-onbefore-onafter-integration-events.bad.al`. +See sample: [`publish-thin-onbefore-onafter-integration-events.bad.al`](publish-thin-onbefore-onafter-integration-events.bad.al). diff --git a/microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.md b/microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.md index fba378b..67d5111 100644 --- a/microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.md +++ b/microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.md @@ -17,10 +17,10 @@ A routine that raises an `OnBefore…` integration event with a `var IsHandled: Reset `IsHandled := false;` before a raise only when the value might otherwise carry over as `true`: the same variable is reused after an earlier raise without a control-flow proof that it is false, a raise is re-entered by a loop, the value comes from an input parameter, field, or global, or earlier code seeds it. Prefer separate fresh locals when independent event seams need independent handled state. A reset on a guaranteed-false fresh local used by one non-looping raise, or before a later raise reached only after a semantically valid `if IsHandled then exit;`, can be retained for readability, but its absence is not a correctness finding. -See sample: `reset-ishandled-only-when-the-value-can-carry-over.good.al`. +See sample: [`reset-ishandled-only-when-the-value-can-carry-over.good.al`](reset-ishandled-only-when-the-value-can-carry-over.good.al). ## Anti Pattern Raising `OnBeforeX(…, IsHandled)` when the variable can still be `true` from an earlier raise, an earlier loop iteration, or another source, so the publisher call starts with stale state. Do not match a single non-looping raise using a fresh local Boolean, or a later raise reached only after a semantically valid `if IsHandled then exit;` proves the value is false. -See sample: `reset-ishandled-only-when-the-value-can-carry-over.bad.al`. +See sample: [`reset-ishandled-only-when-the-value-can-carry-over.bad.al`](reset-ishandled-only-when-the-value-can-carry-over.bad.al). diff --git a/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md index 95a2617..004819b 100644 --- a/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md +++ b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md @@ -17,10 +17,10 @@ The `local` and `internal` access modifiers on Business and Integration event pu Preserve a shipped Business or Integration event's identity and every existing parameter's name, type/subtype, and passing mode regardless of the procedure access modifier. AS0025 protects names and types, while AS0063 and AS0077 protect removal and addition of `var`. New parameters may be added at any position on a `local` or `internal` event because subscribers can omit them; public event procedures follow the stricter caller contract described by `add-new-event-parameters-at-the-end`. -See sample: `treat-local-and-internal-events-as-subscriber-contracts.good.al`. +See sample: [`treat-local-and-internal-events-as-subscriber-contracts.good.al`](treat-local-and-internal-events-as-subscriber-contracts.good.al). ## Anti Pattern Renaming or removing an existing parameter, changing its type/subtype, or adding/removing its `var` modifier because the event publisher procedure is `local` or `internal`. AppSourceCop checks these subscriber-breaking changes because dependent event subscribers can still bind to the event. Reordering unchanged parameters, or inserting a new parameter among them, is not this anti-pattern. -See sample: `treat-local-and-internal-events-as-subscriber-contracts.bad.al`. +See sample: [`treat-local-and-internal-events-as-subscriber-contracts.bad.al`](treat-local-and-internal-events-as-subscriber-contracts.bad.al). diff --git a/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.md b/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.md index d273589..6a6d6ab 100644 --- a/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.md +++ b/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.md @@ -17,10 +17,10 @@ AL has no method overriding, so a `procedure` that runs its body unconditionally Raise `OnBeforeX(…, IsHandled)` as the first step of the routine and guard with `if IsHandled then exit;` before any default logic runs. Declare the publisher `[IntegrationEvent(false, false)] local procedure OnBeforeX(…; var IsHandled: Boolean)` with an empty body, and keep `IsHandled` a `var` parameter so a subscriber can write to it. A subscriber that replaces the behaviour does its work and sets `IsHandled := true`; one that only augments leaves it untouched and guards with `if IsHandled then exit;` itself. Reserve the override hook for cases where a partner genuinely needs to replace logic — when the goal is only to react, a positive `OnAfter` event is the better seam. -See sample: `use-ishandled-to-make-base-behaviour-overridable.good.al`. +See sample: [`use-ishandled-to-make-base-behaviour-overridable.good.al`](use-ishandled-to-make-base-behaviour-overridable.good.al). ## Anti Pattern Two shapes. First, a routine whose default logic always runs because there is no `OnBefore…`/`IsHandled` hook at all — extensions cannot change it without overwriting base code. Second, a routine that raises `OnBeforeX(IsHandled)` but omits the `if IsHandled then exit;` guard, so the default logic still executes after a subscriber set `IsHandled := true`, duplicating work and side effects. Detection: an `OnBefore` publisher with a `var IsHandled: Boolean` parameter whose caller never tests `IsHandled`, or a public routine doing non-trivial work with no overridable seam. -See sample: `use-ishandled-to-make-base-behaviour-overridable.bad.al`. +See sample: [`use-ishandled-to-make-base-behaviour-overridable.bad.al`](use-ishandled-to-make-base-behaviour-overridable.bad.al). diff --git a/microsoft/knowledge/interfaces/assign-codeunit-to-interface-for-testability.md b/microsoft/knowledge/interfaces/assign-codeunit-to-interface-for-testability.md index e0d50d7..8d3e7ce 100644 --- a/microsoft/knowledge/interfaces/assign-codeunit-to-interface-for-testability.md +++ b/microsoft/knowledge/interfaces/assign-codeunit-to-interface-for-testability.md @@ -17,10 +17,10 @@ An interface variable can hold any codeunit that `implements` the interface, ass Declare the dependency as an `Interface` variable on the consumer and supply the implementation from outside — typically setter injection through a procedure that takes an `Interface` parameter, or a parameter on the entry method. Production passes the real implementation codeunit; a test passes a test-double codeunit that implements the same interface with deterministic behaviour. Because a codeunit assigns to an interface variable directly, no enum or factory is needed for the injectable case. The consumer's logic is then verifiable in isolation. -See sample: `assign-codeunit-to-interface-for-testability.good.al`. +See sample: [`assign-codeunit-to-interface-for-testability.good.al`](assign-codeunit-to-interface-for-testability.good.al). ## Anti Pattern A consumer that declares its dependency as a concrete `Codeunit "..."` variable and calls it directly. The collaborator cannot be substituted, so a unit test either runs the production side effects or cannot cover the consumer at all. Detection signal: a `var` of type `Codeunit ""` used for a collaborator that has — or could have — an interface, especially one that performs I/O, posting, or external calls. Extract an interface, depend on the interface variable, and inject the implementation. -See sample: `assign-codeunit-to-interface-for-testability.bad.al`. +See sample: [`assign-codeunit-to-interface-for-testability.bad.al`](assign-codeunit-to-interface-for-testability.bad.al). diff --git a/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md index 2aceec4..4f04d67 100644 --- a/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md +++ b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md @@ -17,10 +17,10 @@ Adding a method to a shipped interface changes the contract every implementing c On BC25 or later, declare a new interface that `extends` the published interface and add the new method there. Existing implementers remain valid for the original contract, while new implementers opt in to the extended contract. For targets BC16 through BC24, where interface inheritance is unavailable, publish a new or versioned sibling interface instead. -See sample: `extend-published-interfaces-dont-edit-them.good.al`. +See sample: [`extend-published-interfaces-dont-edit-them.good.al`](extend-published-interfaces-dont-edit-them.good.al). ## Anti Pattern Adding a procedure directly to an interface that has already shipped. Every dependent implementation must immediately add that procedure, so an otherwise compatible app update breaks its implementers. -See sample: `extend-published-interfaces-dont-edit-them.bad.al`. +See sample: [`extend-published-interfaces-dont-edit-them.bad.al`](extend-published-interfaces-dont-edit-them.bad.al). diff --git a/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md index d3715e6..de50810 100644 --- a/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md +++ b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md @@ -17,10 +17,10 @@ An enum ordinal can remain in persisted data after the enum extension that decla On BC18 or later, set `UnknownValueImplementation = = ;` on an enum that implements an interface and can be persisted. Use an implementation that reports a clear domain error or safely contains the unknown state. Keep `DefaultImplementation` separately when declared but unmapped values also need a fallback. -See sample: `handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al`. +See sample: [`handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al`](handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al). ## Anti Pattern Defining only `DefaultImplementation` and assuming it also handles a stored ordinal whose enum value has disappeared. After an enum extension is uninstalled, converting that unknown ordinal to the interface can produce a technical runtime error instead of controlled handling. -See sample: `handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al`. +See sample: [`handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al`](handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al). diff --git a/microsoft/knowledge/interfaces/prefer-interface-over-case-branching.md b/microsoft/knowledge/interfaces/prefer-interface-over-case-branching.md index 337e0dc..b96b5e8 100644 --- a/microsoft/knowledge/interfaces/prefer-interface-over-case-branching.md +++ b/microsoft/knowledge/interfaces/prefer-interface-over-case-branching.md @@ -17,10 +17,10 @@ When behaviour varies by a discrete "type" — a shipping method, a posting stra Declare an `interface` with the method signatures only (no bodies). Define an `enum` that `implements` the interface and set `Implementation = = ;` on each value, pointing at a codeunit that `implements` the same interface. In the consumer, declare a variable of the interface type, assign the enum value to it, and call the method — the platform dispatches to the codeunit mapped to that value. New variants plug in by adding an enum value and its implementation; existing call sites are untouched. The open/closed boundary lives at the enum, not scattered across `case` blocks. -See sample: `prefer-interface-over-case-branching.good.al`. +See sample: [`prefer-interface-over-case-branching.good.al`](prefer-interface-over-case-branching.good.al). ## Anti Pattern A `case "Shipping Method" of` block that selects behaviour inline, duplicated across the call sites that need it. Each new method forces a synchronized edit to every block, and a missed branch is a silent gap. Detection signal: a `case` statement over an enum value whose branches choose between variant computations or strategies, especially when the same shape appears in more than one procedure. Replace the enum with one that `implements` an interface, move each branch body into an implementation codeunit, and let dispatch happen through an interface variable. -See sample: `prefer-interface-over-case-branching.bad.al`. +See sample: [`prefer-interface-over-case-branching.bad.al`](prefer-interface-over-case-branching.bad.al). diff --git a/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md b/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md index 7d2523e..a5c3bb6 100644 --- a/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md +++ b/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md @@ -17,10 +17,10 @@ An `enum` that `implements` an interface maps each declared value to a codeunit On any extensible enum that implements an interface, set `DefaultImplementation = = ;` at the enum level, pointing at a safe implementation. Values with their own `Implementation` keep using it; declared values without one resolve to the default. Do not rely on this property for persisted ordinals that match no declared enum value. -See sample: `set-defaultimplementation-on-enum.good.al`. +See sample: [`set-defaultimplementation-on-enum.good.al`](set-defaultimplementation-on-enum.good.al). ## Anti Pattern An extensible `enum ... implements ` where at least one value sets no `Implementation` and the enum declares no `DefaultImplementation`. Code that assigns that value to an interface variable and invokes a method throws at the call site, and because the enum is extensible the failing value can be introduced by a third party long after the consumer ships. Detection signal: an enum that implements an interface, has a `value(...)` with no `Implementation`, and no enum-level `DefaultImplementation`. Add a `DefaultImplementation` mapping to close the gap. -See sample: `set-defaultimplementation-on-enum.bad.al`. +See sample: [`set-defaultimplementation-on-enum.bad.al`](set-defaultimplementation-on-enum.bad.al). diff --git a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md index 683a8a0..b72fc41 100644 --- a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md +++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md @@ -17,10 +17,10 @@ Report dataitem field selection is calculated at compile time and once per datai When a dataitem trigger needs an extra field, add that field in `OnPreDataItem` before iteration starts. This supplements the compiler-selected fields and avoids the first just-in-time load and enumerator update when the trigger reads the extra field. -See sample: `addloadfields-in-report-onpredataitem.good.al`. +See sample: [`addloadfields-in-report-onpredataitem.good.al`](addloadfields-in-report-onpredataitem.good.al). ## Anti Pattern Listing every dataset column in `AddLoadFields`, or omitting a known trigger-only field because the dataset already uses other fields. The former is redundant; the latter causes a just-in-time load on first access and can cause repeated loads when the record is copied or passed by value. -See sample: `addloadfields-in-report-onpredataitem.bad.al`. +See sample: [`addloadfields-in-report-onpredataitem.bad.al`](addloadfields-in-report-onpredataitem.bad.al). diff --git a/microsoft/knowledge/performance/apply-filters-before-iterating.md b/microsoft/knowledge/performance/apply-filters-before-iterating.md index 76d78ec..4c64be2 100644 --- a/microsoft/knowledge/performance/apply-filters-before-iterating.md +++ b/microsoft/knowledge/performance/apply-filters-before-iterating.md @@ -17,10 +17,10 @@ A `SetRange` or `SetFilter` placed before `FindSet` narrows the result set at th Move every predicate that can be expressed as an equality or range filter into a `SetRange` or `SetFilter` ahead of the find. Make sure a key (index) exists whose leading fields cover the filter so the optimizer can seek; note that `SetCurrentKey` only sets sort order and is not an index hint (see `setcurrentkey-sets-sort-order-not-index-hint.md`). The loop body should then contain only the work that depends on per-row state. -See sample: `apply-filters-before-iterating.good.al`. +See sample: [`apply-filters-before-iterating.good.al`](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`. +See sample: [`apply-filters-before-iterating.bad.al`](apply-filters-before-iterating.bad.al). diff --git a/microsoft/knowledge/performance/apply-guards-before-get.md b/microsoft/knowledge/performance/apply-guards-before-get.md index 9e77411..8d4a876 100644 --- a/microsoft/knowledge/performance/apply-guards-before-get.md +++ b/microsoft/knowledge/performance/apply-guards-before-get.md @@ -17,10 +17,10 @@ A `Get` (or any other database call) executed before a guard that may exit the p 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`. +See sample: [`apply-guards-before-get.good.al`](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`. +See sample: [`apply-guards-before-get.bad.al`](apply-guards-before-get.bad.al). diff --git a/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md index 66ee684..20f27ee 100644 --- a/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md +++ b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md @@ -17,10 +17,10 @@ Microsoft's [AL database-method performance guidance](https://learn.microsoft.co Use `FindSet(true)` when the loop writes the traversed rows, and call `Modify` or `Delete` on that iterating record variable. If generic code is required, open and iterate the `RecordRef` directly instead of calling `GetTable` for each typed record. Keep a per-row loop when validation or row-specific behavior is required; this rule does not imply that `ModifyAll` or `DeleteAll` is equivalent. -See sample: `avoid-cloning-records-before-modify-delete-in-loops.good.al`. +See sample: [`avoid-cloning-records-before-modify-delete-in-loops.good.al`](avoid-cloning-records-before-modify-delete-in-loops.good.al). ## Anti Pattern Inside an active traversal, copy the current row, convert it with `RecordRef.GetTable`, or pass it without `var` to a helper, then call `Modify` or `Delete` on that clone. Do not flag read-only snapshots, temporary records, or copies used to write a different target table; the documented extra-statement concern is clone-before-write on the traversed table. -See sample: `avoid-cloning-records-before-modify-delete-in-loops.bad.al`. +See sample: [`avoid-cloning-records-before-modify-delete-in-loops.bad.al`](avoid-cloning-records-before-modify-delete-in-loops.bad.al). diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.md b/microsoft/knowledge/performance/avoid-commit-inside-loops.md index 25ad958..011fd39 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.md +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.md @@ -21,10 +21,10 @@ A durability checkpoint inside an outer batch loop can be valid only when the sa If the batch is large enough that a single transaction is untenable, use an ordered primary-key watermark and retrieve a bounded next-N key list. The sample uses a query capped by [`TopNumberOfRows`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/query/queryinstance-topnumberofrows-method) to fill a temporary key buffer, then takes update locks and modifies only those exact keys. It does not reconstruct an inclusive first-to-last range that concurrent inserts could expand. Persist the last selected key in the same transaction as the completed chunk, then commit after the bounded helper returns. Use a stable key and define how a later run handles records inserted at or below an already committed watermark. Let errors escape so failed work is not recorded as complete. A `Codeunit.Run` boundary can also own a chunk when its implicit commit and error behavior fit the caller — see `codeunit-run-as-atomic-sub-operation.md`. -See sample: `avoid-commit-inside-loops.good.al`. +See sample: [`avoid-commit-inside-loops.good.al`](avoid-commit-inside-loops.good.al). ## Anti Pattern Placing Commit inside `repeat ... until Next() = 0` without persisted progress is almost always a mistake: retries re-enter already committed work, while the cost of starting a transaction on every row dominates the operation. A progress variable held only in memory is not restart-safe. A full-tail `FindSet` with a commit every N rows is not bounded retrieval, even if a persisted watermark makes it restart-safe. A capped query that discovers only an upper key and then re-reads an inclusive key range is not exact batching either; concurrent inserts inside that range can enlarge the checkpoint. -See sample: `avoid-commit-inside-loops.bad.al`. +See sample: [`avoid-commit-inside-loops.bad.al`](avoid-commit-inside-loops.bad.al). diff --git a/microsoft/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md b/microsoft/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md index 20e6968..5790a6c 100644 --- a/microsoft/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md +++ b/microsoft/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md @@ -19,10 +19,10 @@ application-area: [all] Put display-only results in page variables assigned in `OnAfterGetRecord` without calling `Update`. If the page must refresh after an action, call `CurrPage.Update(false)` from `OnAction` once, not per row. -See sample: `avoid-currpage-update-in-onaftergetrecord.good.al`. +See sample: [`avoid-currpage-update-in-onaftergetrecord.good.al`](avoid-currpage-update-in-onaftergetrecord.good.al). ## Anti Pattern `trigger OnAfterGetRecord() begin ... CurrPage.Update(); end;` on a list. The signal is `CurrPage.Update` inside `OnAfterGetRecord` or `OnAfterGetCurrRecord` without an explicit user action. -See sample: `avoid-currpage-update-in-onaftergetrecord.bad.al`. +See sample: [`avoid-currpage-update-in-onaftergetrecord.bad.al`](avoid-currpage-update-in-onaftergetrecord.bad.al). 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 index f77fbfe..db4f5e1 100644 --- a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md +++ b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md @@ -17,10 +17,10 @@ A `Get` or `FindFirst` against another persistent table inside a loop can produc Use a query object to join the outer and inner tables when the relationship and filters can be expressed as one query. If keys repeat, a dictionary cache can reduce lookups to one per distinct key. `SetLoadFields` can reduce the columns transferred by unavoidable inner reads, but it does not eliminate the N+1 shape and must not be presented as doing so. -See sample: `avoid-get-inside-loop-on-large-table.good.al`. +See sample: [`avoid-get-inside-loop-on-large-table.good.al`](avoid-get-inside-loop-on-large-table.good.al). ## Anti Pattern Iterating production BOM lines and calling `Item.Get(BOMLine."No.")` for each line when the same result can be produced by a query joining Production BOM Line to Item. Partial loading alone is only a payload mitigation for this pattern. -See sample: `avoid-get-inside-loop-on-large-table.bad.al`. +See sample: [`avoid-get-inside-loop-on-large-table.bad.al`](avoid-get-inside-loop-on-large-table.bad.al). diff --git a/microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md b/microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md index 966e0dd..82a3bb7 100644 --- a/microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md +++ b/microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md @@ -19,10 +19,10 @@ A codeunit with `SingleInstance = true` is allocated once per session and lives Keep the global footprint on a SingleInstance subscriber bounded and intentional: a handful of flags, a setup record, a bounded cache with a maximum size. When cross-event state is genuinely needed, define an explicit reset point — end of a business process, arrival of a specific terminal event — that clears the growing collection. -See sample: `avoid-growing-globals-in-singleinstance-subscribers.good.al`. +See sample: [`avoid-growing-globals-in-singleinstance-subscribers.good.al`](avoid-growing-globals-in-singleinstance-subscribers.good.al). ## Anti Pattern A SingleInstance subscriber that appends each event's payload to a global list, dictionary, or temporary record without a cap or cleanup trigger. The list grows for hours, memory pressure builds quietly, and debugging the root cause on a live environment is substantially harder than noticing the unbounded append in code review. -See sample: `avoid-growing-globals-in-singleinstance-subscribers.bad.al`. +See sample: [`avoid-growing-globals-in-singleinstance-subscribers.bad.al`](avoid-growing-globals-in-singleinstance-subscribers.bad.al). diff --git a/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md index b718449..6aae177 100644 --- a/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md +++ b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md @@ -17,10 +17,10 @@ application-area: [all] 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`. +See sample: [`avoid-recordref-in-hot-loop.good.al`](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`. +See sample: [`avoid-recordref-in-hot-loop.bad.al`](avoid-recordref-in-hot-loop.bad.al). 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 index 9684769..3859b7c 100644 --- a/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md +++ b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md @@ -17,10 +17,10 @@ A list or card page's `OnAfterGetRecord` trigger fires *because* the platform ha 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`. +See sample: [`avoid-redundant-get-when-record-already-loaded.good.al`](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`. +See sample: [`avoid-redundant-get-when-record-already-loaded.bad.al`](avoid-redundant-get-when-record-already-loaded.bad.al). diff --git a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md index 834641a..bb03f41 100644 --- a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md +++ b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md @@ -17,10 +17,10 @@ A `Confirm`, `StrMenu`, modal page, or other user prompt issued from inside a wr 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`. +See sample: [`avoid-user-prompts-inside-transactions.good.al`](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`. +See sample: [`avoid-user-prompts-inside-transactions.bad.al`](avoid-user-prompts-inside-transactions.bad.al). diff --git a/microsoft/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md b/microsoft/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md index 616c789..cdb7405 100644 --- a/microsoft/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md +++ b/microsoft/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md @@ -19,10 +19,10 @@ application-area: [all] Inside a multi-row insert, call `"No. Series - Batch".GetNextNo` per row and `SaveState` once after the loop when the series must remain gapless. Use `NumberSequence.Next` when holes are allowed. Do not replace a single `OnInsert` `GetNextNo` for one master record; that path is not the hotspot. -See sample: `batch-number-series-instead-of-getnextno-per-row.good.al`. +See sample: [`batch-number-series-instead-of-getnextno-per-row.good.al`](batch-number-series-instead-of-getnextno-per-row.good.al). ## Anti Pattern `NoSeries.GetNextNo(...)` inside `repeat ... Insert ... until Next() = 0` where the series is **gapless** (Allow Gaps = false). Each iteration takes the series-line lock. The signal is `"No. Series"` (not `"No. Series - Batch"`) in a loop that inserts more than one row; do not flag the same pattern when the series has Allow Gaps enabled, as the `NumberSequence` path already avoids the lock. -See sample: `batch-number-series-instead-of-getnextno-per-row.bad.al`. +See sample: [`batch-number-series-instead-of-getnextno-per-row.bad.al`](batch-number-series-instead-of-getnextno-per-row.bad.al). diff --git a/microsoft/knowledge/performance/boolean-operators-do-not-short-circuit.md b/microsoft/knowledge/performance/boolean-operators-do-not-short-circuit.md index 7c4073b..9c840a7 100644 --- a/microsoft/knowledge/performance/boolean-operators-do-not-short-circuit.md +++ b/microsoft/knowledge/performance/boolean-operators-do-not-short-circuit.md @@ -23,13 +23,13 @@ For an `or`-shaped condition, do not nest: nesting `if A then if B then Action` Where a chain of `and`-guards runs past about three conditions, stop nesting and use a `case` statement instead — see `case-true-of-for-long-condition-chains.md`. Keep `and` and `or` for operands that are independently safe and cheap — in-memory field comparisons, enum tests, bound checks — where combining them reads better and costs nothing. -See sample: `boolean-operators-do-not-short-circuit.good.al`. +See sample: [`boolean-operators-do-not-short-circuit.good.al`](boolean-operators-do-not-short-circuit.good.al). ## Anti Pattern A single condition that joins a guard with an operand depending on that guard, or with an expensive operand, using `and` or `or`. The consequence is either wasted work on every evaluation — a database call or validation procedure invoked even when the outcome is already decided — or a runtime error or silently wrong result that the guard was written to prevent. Applying the `and` fix to an `or` condition is a distinct mistake: rewriting `A or B` as nested `if`s drops the `A`-true/`B`-false case instead of preserving it. Detection signals: an operand that indexes an array or list with a variable whose bounds are checked in a sibling operand; `Record.Get(...)` or a `Find`/`IsEmpty` call as one operand of `and` with a field read of the same record as another; an expensive or unsafe operand combined with `or` next to a condition that alone already makes the result true; a boolean-returning procedure call combined with a cheap field test. The pattern is common in code ported from a language that does short-circuit, and in conditions grown by appending a clause to an existing `if`. -See sample: `boolean-operators-do-not-short-circuit.bad.al`. +See sample: [`boolean-operators-do-not-short-circuit.bad.al`](boolean-operators-do-not-short-circuit.bad.al). ## See also diff --git a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md index 7d0752f..f4d7ad9 100644 --- a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md +++ b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md @@ -17,10 +17,10 @@ application-area: [all] 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`. +See sample: [`calcsums-instead-of-calcfields-in-loop.good.al`](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`. +See sample: [`calcsums-instead-of-calcfields-in-loop.bad.al`](calcsums-instead-of-calcfields-in-loop.bad.al). diff --git a/microsoft/knowledge/performance/case-true-of-for-long-condition-chains.md b/microsoft/knowledge/performance/case-true-of-for-long-condition-chains.md index 1e0457e..1ec3863 100644 --- a/microsoft/knowledge/performance/case-true-of-for-long-condition-chains.md +++ b/microsoft/knowledge/performance/case-true-of-for-long-condition-chains.md @@ -17,13 +17,13 @@ Because AL gives no short-circuit guarantee for `and` and `or`, a chain of condi Sequence two or three dependent conditions with nested `if`. Beyond that, switch to `case`: use `case false of` for a chain of guards where every condition must hold, letting control fall past `end` when all of them pass; use `case true of` for first-match dispatch, where each later probe runs only if the earlier ones did not match. Comma-separate conditions into one value set only when every one of them is a pure, order-independent test with no side effect — a field comparison, an enum check, a bound test — so it makes no difference whether AL evaluates all of them or stops early; grouping these costs nothing and removes the repeated action. A condition that guards another, or that carries a side effect or a cost of its own — a `Get`, a `Find`, a procedure call — keeps its own value set, placed immediately after the value set it depends on, so the code relies only on the ordering the documentation actually states. A value set needs no parentheses around a comparison, unlike an operand of `and` or `or`: the AL operator hierarchy places `and` and `or` above the comparison operators, so parentheses are mandatory there and the chain fills up with them. This keeps every condition at one indentation level, makes evaluation order explicit rather than implied by nesting, and preserves the stop-at-first-match behaviour it relies on. It also aligns with the AL programming convention that more than two alternatives belong in a `case` statement rather than an `if-then-else`. -See sample: `case-true-of-for-long-condition-chains.good.al`. +See sample: [`case-true-of-for-long-condition-chains.good.al`](case-true-of-for-long-condition-chains.good.al). ## Anti Pattern An `if` ladder four or more levels deep whose only purpose is sequencing guards. Detection: a chain of nested `if` statements with no `else`, each condition guarding the one below it, terminating in a single action or `exit`; or the same `exit`/`error` duplicated at every level of such a nested chain, purely to escape it. The second, worse form is collapsing that ladder into one `and` chain to escape the nesting — that trades indentation for a real defect, because the operands are still all evaluated. A third, subtler form is over-applying the comma-grouping itself: putting a guard and the condition it protects — for example `Item.Get(...)` and a read of a field on that same record — into one comma-separated value set. That relies on an evaluation order within a single value set that the documentation does not state; keep them in separate value sets instead. Reach for `case` over nested `if` or a collapsed `and` chain, and keep order-dependent conditions in their own value sets within it. -See sample: `case-true-of-for-long-condition-chains.bad.al`. +See sample: [`case-true-of-for-long-condition-chains.bad.al`](case-true-of-for-long-condition-chains.bad.al). ## See also diff --git a/microsoft/knowledge/performance/changecompany-in-loop-drops-caches.md b/microsoft/knowledge/performance/changecompany-in-loop-drops-caches.md index fddef76..5946e42 100644 --- a/microsoft/knowledge/performance/changecompany-in-loop-drops-caches.md +++ b/microsoft/knowledge/performance/changecompany-in-loop-drops-caches.md @@ -19,10 +19,10 @@ application-area: [all] Group work by company. Call `ChangeCompany` once per distinct company, then `FindSet`/`Get` that company's rows. If the record variable is reused afterward, call `ChangeCompany()` without a company name to redirect it back to the current company. -See sample: `changecompany-in-loop-drops-caches.good.al`. +See sample: [`changecompany-in-loop-drops-caches.good.al`](changecompany-in-loop-drops-caches.good.al). ## Anti Pattern `repeat Rec.ChangeCompany(Buffer.Company); Rec.Get(Buffer."No."); until Buffer.Next() = 0` when `Buffer` is not ordered by company, or even when it is — if `ChangeCompany` still runs every row. The signal is `ChangeCompany` inside `repeat`/`while` keyed by a document line rather than by a company loop. -See sample: `changecompany-in-loop-drops-caches.bad.al`. +See sample: [`changecompany-in-loop-drops-caches.bad.al`](changecompany-in-loop-drops-caches.bad.al). diff --git a/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md b/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md index 3261a2c..1a60051 100644 --- a/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md +++ b/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md @@ -19,7 +19,7 @@ application-area: [all] Measure aggregate-read latency and write cost under realistic filters and volumes. Keep `MaintainSIFTIndex = true` when the maintained aggregate materially benefits frequent `CalcSums` or FlowField reads. Consider `false` when writes dominate and the less-frequent aggregate reads can tolerate calculation from the base table. -See sample: `choose-maintainsiftindex-by-read-write-ratio.good.al`. +See sample: [`choose-maintainsiftindex-by-read-write-ratio.good.al`](choose-maintainsiftindex-by-read-write-ratio.good.al). ## Anti Pattern diff --git a/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md index 89777f1..8abdd20 100644 --- a/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md +++ b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md @@ -17,10 +17,10 @@ application-area: [all] 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`. +See sample: [`codeunit-run-as-atomic-sub-operation.good.al`](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`. +See sample: [`codeunit-run-as-atomic-sub-operation.bad.al`](codeunit-run-as-atomic-sub-operation.bad.al). 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 index a07520f..936ae5b 100644 --- a/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md +++ b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md @@ -17,10 +17,10 @@ application-area: [all] 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`. +See sample: [`codeunit-run-requires-prior-commit-inside-transaction.good.al`](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`. +See sample: [`codeunit-run-requires-prior-commit-inside-transaction.bad.al`](codeunit-run-requires-prior-commit-inside-transaction.bad.al). diff --git a/microsoft/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md b/microsoft/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md index 89ee55e..943242d 100644 --- a/microsoft/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md +++ b/microsoft/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md @@ -19,10 +19,10 @@ application-area: [all] On report objects and `PageType = API` pages with `Editable = false` that never write, set `DataAccessIntent = ReadOnly`. For query objects, set it when the query is consumed via OData or an API endpoint. Keep the default on objects that insert, modify, or call a write codeunit from a processing-only report. -See sample: `dataaccessintent-readonly-on-analytical-objects.good.al`. +See sample: [`dataaccessintent-readonly-on-analytical-objects.good.al`](dataaccessintent-readonly-on-analytical-objects.good.al). ## Anti Pattern A listing report or API query with no `DataAccessIntent` that scans G/L or sales lines. The object is read-only in practice and still loads the primary. -See sample: `dataaccessintent-readonly-on-analytical-objects.bad.al`. +See sample: [`dataaccessintent-readonly-on-analytical-objects.bad.al`](dataaccessintent-readonly-on-analytical-objects.bad.al). 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 index f25af2e..2580144 100644 --- a/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md +++ b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md @@ -17,10 +17,10 @@ application-area: [all] 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`. +See sample: [`do-not-locktable-in-read-only-procedure.good.al`](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`. +See sample: [`do-not-locktable-in-read-only-procedure.bad.al`](do-not-locktable-in-read-only-procedure.bad.al). diff --git a/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md index 9de0903..a30ada9 100644 --- a/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md +++ b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md @@ -17,10 +17,10 @@ A list page's `OnAfterGetRecord` fires once per visible row, every time the user 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`. +See sample: [`do-not-modify-in-onaftergetrecord.good.al`](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`. +See sample: [`do-not-modify-in-onaftergetrecord.bad.al`](do-not-modify-in-onaftergetrecord.bad.al). 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 index a7215be..ef4b137 100644 --- a/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md +++ b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md @@ -17,10 +17,10 @@ application-area: [all] 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`. +See sample: [`do-not-remove-sourcetabletemporary-from-api-page.good.al`](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`. +See sample: [`do-not-remove-sourcetabletemporary-from-api-page.bad.al`](do-not-remove-sourcetabletemporary-from-api-page.bad.al). diff --git a/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md index 48a1deb..3ab780a 100644 --- a/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md +++ b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md @@ -17,10 +17,10 @@ application-area: [all] 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`. +See sample: [`findset-true-applies-updlock-on-read.good.al`](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`. +See sample: [`findset-true-applies-updlock-on-read.bad.al`](findset-true-applies-updlock-on-read.bad.al). diff --git a/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md index 3a63613..fdedc42 100644 --- a/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md +++ b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md @@ -17,10 +17,10 @@ A FlowField is computed by SQL on demand. CodeCop AA0232 — "FlowFields should 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`. +See sample: [`flowfield-source-key-needs-sumindexfields.good.al`](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`. +See sample: [`flowfield-source-key-needs-sumindexfields.bad.al`](flowfield-source-key-needs-sumindexfields.bad.al). diff --git a/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md index c466ffe..d56c716 100644 --- a/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md +++ b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md @@ -17,10 +17,10 @@ Event subscribers fire on every event matching their signature — for `OnAfterV 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`. +See sample: [`guard-event-subscribers-before-db-call.good.al`](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`. +See sample: [`guard-event-subscribers-before-db-call.bad.al`](guard-event-subscribers-before-db-call.bad.al). diff --git a/microsoft/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md b/microsoft/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md index 01dc1f8..bf0bb7d 100644 --- a/microsoft/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md +++ b/microsoft/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md @@ -19,10 +19,10 @@ Pages exposed as OData, including Edit in Excel, still run AL page triggers for Wrap UI-only work — FactBox refresh, notifications, defaulting that is not part of the web-service contract — in `if GuiAllowed then`. Keep the OData path to field values the API actually returns. -See sample: `guiallowed-guard-on-pages-used-as-odata.good.al`. +See sample: [`guiallowed-guard-on-pages-used-as-odata.good.al`](guiallowed-guard-on-pages-used-as-odata.good.al). ## Anti Pattern Unconditional FactBox or calculation logic in `OnAfterGetRecord` / `OnAfterGetCurrRecord` on a page that is published as a web service or used with Edit in Excel. The signal is trigger work that calls `CurrPage` parts or extra queries without a `GuiAllowed` guard. -See sample: `guiallowed-guard-on-pages-used-as-odata.bad.al`. +See sample: [`guiallowed-guard-on-pages-used-as-odata.bad.al`](guiallowed-guard-on-pages-used-as-odata.bad.al). diff --git a/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.md b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.md index b03ac51..7db17d3 100644 --- a/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.md +++ b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.md @@ -17,10 +17,10 @@ By default, a FlowField used directly as a page control's source is calculated w On BC 26 and later, enable and verify the visible-only FlowField feature before relying on `Visible` to suppress calculation. When the target environment does not guarantee that option, avoid binding an expensive FlowField directly to a usually-hidden control: calculate it only in the branch that displays it and bind the page control to a variable. Do not flag a hidden FlowField when the v26 feature is known to be enabled or the FlowField is cheap and intentionally preloaded. -See sample: `hidden-flowfields-still-calculate-before-bc26-opt-in.good.al`. +See sample: [`hidden-flowfields-still-calculate-before-bc26-opt-in.good.al`](hidden-flowfields-still-calculate-before-bc26-opt-in.good.al). ## Anti Pattern Adding a costly Sum or Lookup FlowField to a page with `Visible = SomeRareMode` and assuming the hidden state prevents its query on all supported versions. The review signal is the direct FlowField source plus conditional or false visibility, not visibility alone. -See sample: `hidden-flowfields-still-calculate-before-bc26-opt-in.bad.al`. +See sample: [`hidden-flowfields-still-calculate-before-bc26-opt-in.bad.al`](hidden-flowfields-still-calculate-before-bc26-opt-in.bad.al). diff --git a/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md b/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md index 4c1d500..21a2c04 100644 --- a/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md +++ b/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md @@ -21,10 +21,10 @@ Defer the HTTP call to a separate session. When the external operation must corr A directly created scheduled task is suitable only when its work is independent of the caller's commit. An immediately ready task can run concurrently with the caller, so it must not assume that the caller's writes are already committed. Do **not** use `Commit()` as a general remedy: it irrevocably commits all prior writes in the current transaction, so any subsequent failure cannot roll them back. `Commit()` is appropriate only at top-level entry points where partial persistence is intentional and understood. -See sample: `httpclient-inside-write-transaction-holds-locks.good.al`. +See sample: [`httpclient-inside-write-transaction-holds-locks.good.al`](httpclient-inside-write-transaction-holds-locks.good.al). ## Anti Pattern `Modify`/`Insert` followed by `HttpClient` in the same procedure with no `Commit` between them. Detection signal: any `HttpClient` use after a write on the same execution path, especially in posting, page actions, or subscribers. -See sample: `httpclient-inside-write-transaction-holds-locks.bad.al`. +See sample: [`httpclient-inside-write-transaction-holds-locks.bad.al`](httpclient-inside-write-transaction-holds-locks.bad.al). diff --git a/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.md b/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.md index a8bf187..c88cdfe 100644 --- a/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.md +++ b/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.md @@ -19,10 +19,10 @@ application-area: [all] When the body iterates, open with `if Rec.FindSet() then repeat ... until Next() = 0`. Do not flag a bare `FindSet` loop as missing an `IsEmpty` precondition. Reserve `IsEmpty` for branches that never materialize the row set. -See sample: `isempty-before-findset-is-extra-round-trip.good.al`. +See sample: [`isempty-before-findset-is-extra-round-trip.good.al`](isempty-before-findset-is-extra-round-trip.good.al). ## Anti Pattern `if not Rec.IsEmpty() then if Rec.FindSet() then repeat`. Also a false-positive review comment that asks to add that guard. The second read does not avoid the first; it duplicates it. -See sample: `isempty-before-findset-is-extra-round-trip.bad.al`. +See sample: [`isempty-before-findset-is-extra-round-trip.bad.al`](isempty-before-findset-is-extra-round-trip.bad.al). diff --git a/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md index 257b0fb..6a48663 100644 --- a/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md +++ b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md @@ -19,10 +19,10 @@ When a known input determines which fields a subsequent record read will use, a Call `SetLoadFields` with the common fields. In each branch, call `AddLoadFields` with that branch's normal fields and then perform the record read. This applies only when the discriminator is known before the read; branching on a field from an already-loaded row is too late to tailor that row's initial SQL projection. -See sample: `load-common-fields-before-branching-on-case.good.al`. +See sample: [`load-common-fields-before-branching-on-case.good.al`](load-common-fields-before-branching-on-case.good.al). ## Anti Pattern A single top-level `SetLoadFields` enumerating every branch's fields, or a branch-local `SetLoadFields` that accidentally discards the common selection. Both make the declared load plan differ from the fields the selected path actually uses. -See sample: `load-common-fields-before-branching-on-case.bad.al`. +See sample: [`load-common-fields-before-branching-on-case.bad.al`](load-common-fields-before-branching-on-case.bad.al). diff --git a/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.md b/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.md index 533ab8c..4e88b83 100644 --- a/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.md +++ b/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.md @@ -19,10 +19,10 @@ Work that uses a record only for its identity — passing it to another procedur When the iterating code's body touches only primary key fields (or passes the record to another procedure that will apply its own `SetLoadFields`), declare `SetLoadFields` with just the primary key fields before applying filters and calling `FindSet`. Callers downstream that need more fields issue their own `Get` or extend the load explicitly. -See sample: `load-only-primary-key-fields-for-reference-work.good.al`. +See sample: [`load-only-primary-key-fields-for-reference-work.good.al`](load-only-primary-key-fields-for-reference-work.good.al). ## Anti Pattern Using the default full-record load in loops whose body only reads the primary key, or forwards the record to another codeunit that immediately re-queries. The non-key payload is fetched across the wire and held in memory for the duration of the loop, then discarded unread. -See sample: `load-only-primary-key-fields-for-reference-work.bad.al`. +See sample: [`load-only-primary-key-fields-for-reference-work.bad.al`](load-only-primary-key-fields-for-reference-work.bad.al). diff --git a/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md index e540859..8898e44 100644 --- a/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md +++ b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md @@ -17,7 +17,7 @@ application-area: [all] 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`. +See sample: [`maintainsqlindex-false-breaks-flowfield-sift.bad.al`](maintainsqlindex-false-breaks-flowfield-sift.bad.al). ## Anti Pattern diff --git a/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md b/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md index 3cb8459..e8da1c5 100644 --- a/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md +++ b/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md @@ -19,10 +19,10 @@ application-area: [all] Keep company-open subscribers to cheap in-memory work: set a flag, enqueue a job-queue entry, or `TaskScheduler.CreateTask`. Perform HTTP and large SQL after the session is running, in that background work. -See sample: `oncompanyopen-subscribers-must-not-do-io.good.al`. +See sample: [`oncompanyopen-subscribers-must-not-do-io.good.al`](oncompanyopen-subscribers-must-not-do-io.good.al). ## Anti Pattern An `OnAfterLogin` / `OnCompanyOpenCompleted` subscriber that calls `HttpClient` or scans a ledger. Detection signal: `HttpClient`, `FindSet`, or `CalcFields` inside a subscriber bound to those events. -See sample: `oncompanyopen-subscribers-must-not-do-io.bad.al`. +See sample: [`oncompanyopen-subscribers-must-not-do-io.bad.al`](oncompanyopen-subscribers-must-not-do-io.bad.al). diff --git a/microsoft/knowledge/performance/order-case-branches-by-frequency.md b/microsoft/knowledge/performance/order-case-branches-by-frequency.md index 1634475..dd327e9 100644 --- a/microsoft/knowledge/performance/order-case-branches-by-frequency.md +++ b/microsoft/knowledge/performance/order-case-branches-by-frequency.md @@ -19,10 +19,10 @@ AL documentation does not guarantee that a `case` statement uses a linear compar After profiling confirms the comparison path matters and the runtime frequency is known, list common branches first without changing the set of handled values, fallback behavior, or branch bodies. -See sample: `order-case-branches-by-frequency.good.al`. +See sample: [`order-case-branches-by-frequency.good.al`](order-case-branches-by-frequency.good.al). ## Anti Pattern Reordering branches based on assumed frequency without profiling, or changing an `else` arm or handled value while making the optimization. The good and bad forms must differ only in branch order. -See sample: `order-case-branches-by-frequency.bad.al`. +See sample: [`order-case-branches-by-frequency.bad.al`](order-case-branches-by-frequency.bad.al). diff --git a/microsoft/knowledge/performance/page-background-tasks-for-expensive-cues.md b/microsoft/knowledge/performance/page-background-tasks-for-expensive-cues.md index 48fae1c..8b2bb39 100644 --- a/microsoft/knowledge/performance/page-background-tasks-for-expensive-cues.md +++ b/microsoft/knowledge/performance/page-background-tasks-for-expensive-cues.md @@ -19,10 +19,10 @@ Role-center cues and CardPart totals that run `CalcFields`, scans, or HTTP on th Bind the cue to a page variable, enqueue a read-only calculation from `OnAfterGetCurrRecord` (not `OnAfterGetRecord` on a list), and apply the result in `OnPageBackgroundTaskCompleted`. Show a placeholder until then. -See sample: `page-background-tasks-for-expensive-cues.good.al`. +See sample: [`page-background-tasks-for-expensive-cues.good.al`](page-background-tasks-for-expensive-cues.good.al). ## Anti Pattern `CalcFields` or a ledger `Count` in `OnOpenPage` / `OnAfterGetCurrRecord` of a CueGroup CardPart with no background task. The Role Center waits on SQL the user may never look at. -See sample: `page-background-tasks-for-expensive-cues.bad.al`. +See sample: [`page-background-tasks-for-expensive-cues.bad.al`](page-background-tasks-for-expensive-cues.bad.al). diff --git a/microsoft/knowledge/performance/pair-findset-with-next-loop.md b/microsoft/knowledge/performance/pair-findset-with-next-loop.md index 80f855c..12d3bd4 100644 --- a/microsoft/knowledge/performance/pair-findset-with-next-loop.md +++ b/microsoft/knowledge/performance/pair-findset-with-next-loop.md @@ -17,10 +17,10 @@ Two CodeCop rules carve out the loop pattern. AA0181 says `FindSet()`/`Find()` " 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`. +See sample: [`pair-findset-with-next-loop.good.al`](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`. +See sample: [`pair-findset-with-next-loop.bad.al`](pair-findset-with-next-loop.bad.al). 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 index 45214b8..4fc83c4 100644 --- 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 @@ -17,7 +17,7 @@ application-area: [all] 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`. +See sample: [`pass-false-to-insert-when-trigger-not-needed.good.al`](pass-false-to-insert-when-trigger-not-needed.good.al). ## Anti Pattern diff --git a/microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md b/microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md index ce4c4bc..726ec41 100644 --- a/microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md +++ b/microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md @@ -19,10 +19,10 @@ A `FindSet`/`Next` loop builds an enumerator from the fields selected for load. Helpers that read extra fields on an in-flight iterator must take the record as `var`, or the caller must `AddLoadFields` those fields before the loop. Prefer declaring the extra fields up front so no JIT is needed. -See sample: `pass-var-record-to-preserve-partial-load-enumerator.good.al`. +See sample: [`pass-var-record-to-preserve-partial-load-enumerator.good.al`](pass-var-record-to-preserve-partial-load-enumerator.good.al). ## Anti Pattern A `SetLoadFields` loop that passes the iterator by value into a helper which then reads a field that was not loaded. The first row pays one JIT; every subsequent row pays it again because the enumerator never learned the extra field. -See sample: `pass-var-record-to-preserve-partial-load-enumerator.bad.al`. +See sample: [`pass-var-record-to-preserve-partial-load-enumerator.bad.al`](pass-var-record-to-preserve-partial-load-enumerator.bad.al). diff --git a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md index 024aae1..ad7a6cf 100644 --- a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md @@ -17,10 +17,10 @@ application-area: [all] Use `ModifyAll` when the loop directly assigns the same value, does not call `Validate`, needs no per-row calculation, and does not depend on `OnModify` unless the equivalent `RunTrigger` value is supplied. Check whether table trigger code, related subscribers, security filtering, `Media`/`MediaSet`, or companion fields force row-by-row fallback (see `triggers-and-media-field-regress-modifyall.md`). A visible loop for progress UX is acceptable only when evidence shows the equivalent bulk call already executes as individual operations and the loop preserves trigger and business semantics. -See sample: `prefer-modifyall-over-per-row-modify.good.al`. +See sample: [`prefer-modifyall-over-per-row-modify.good.al`](prefer-modifyall-over-per-row-modify.good.al). ## Anti Pattern A loop that only assigns a constant and calls `Modify(false)` on a field with no validation side effects or bulk fallback condition. A progress dialog alone does not exempt this loop. Conversely, replacing `Validate(Field, Value); Modify(true)` with `ModifyAll(Field, Value)` is also an anti-pattern because it silently drops field validation and may drop table-trigger behavior. -See sample: `prefer-modifyall-over-per-row-modify.bad.al`. +See sample: [`prefer-modifyall-over-per-row-modify.bad.al`](prefer-modifyall-over-per-row-modify.bad.al). diff --git a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md index f798636..a82ec8b 100644 --- a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md +++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md @@ -17,10 +17,10 @@ Without read scale-out, `LockTable` causes subsequent reads of that table in the For a read-only operation that specifically requires committed data, set `Rec.ReadIsolation := IsolationLevel::ReadCommitted` immediately before the read. If the default isolation is sufficient, set neither property. `ReadCommitted` can still block behind writers and does not guarantee that repeated reads stay unchanged; use the isolation level required by the operation. Reserve update locks for read-before-write logic, not read-only helpers. -See sample: `prefer-readisolation-over-locktable-for-reads.good.al`. +See sample: [`prefer-readisolation-over-locktable-for-reads.good.al`](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". It takes stronger isolation than the helper needs and changes later reads of that table in the surrounding transaction or read-scale-out session. -See sample: `prefer-readisolation-over-locktable-for-reads.bad.al`. +See sample: [`prefer-readisolation-over-locktable-for-reads.bad.al`](prefer-readisolation-over-locktable-for-reads.bad.al). diff --git a/microsoft/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md b/microsoft/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md index dd03115..9097b5a 100644 --- a/microsoft/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md +++ b/microsoft/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md @@ -20,10 +20,10 @@ Since v23, all extensions on the same base table share at most one companion-tab Put optional, sparse, or integration attributes in a related table with the ledger entry number as primary key. Show them from a FactBox or a FlowField. Use a tableextension stored field only when the value must appear as a native list column and is read on almost every access. -See sample: `prefer-related-table-over-extension-on-hot-ledgers.good.al`. +See sample: [`prefer-related-table-over-extension-on-hot-ledgers.good.al`](prefer-related-table-over-extension-on-hot-ledgers.good.al). ## Anti Pattern `tableextension` on `"G/L Entry"` (or another posting table) that adds several stored `Text`/`Blob` fields used only by one integration. The companion join is paid on every posting and on any AL code path that loads extension fields, even when those columns are not needed for the current operation. -See sample: `prefer-related-table-over-extension-on-hot-ledgers.bad.al`. +See sample: [`prefer-related-table-over-extension-on-hot-ledgers.bad.al`](prefer-related-table-over-extension-on-hot-ledgers.bad.al). diff --git a/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.md b/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.md index 1f3205f..5bcfbd8 100644 --- a/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.md +++ b/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.md @@ -19,10 +19,10 @@ The Business Central server caches primary-key `Get` calls within a transaction. Keep `Record.Get` for repeated lookups of the same primary keys in one transaction. Use a Query when the work is a true join or aggregation that the record API would express as nested scans. Do not flag a guarded `Get` on a repeating key as an N+1 solely because a Query could express the same columns. -See sample: `query-results-bypass-primary-key-cache.good.al`. +See sample: [`query-results-bypass-primary-key-cache.good.al`](query-results-bypass-primary-key-cache.good.al). ## Anti Pattern Rewriting a helper that `Get`s Customer by `No.` on every sales line into a Query opened inside that helper. Distinct line customers still need a lookup; repeating customers were already served from the PK cache. The Query pays SQL every time. -See sample: `query-results-bypass-primary-key-cache.bad.al`. +See sample: [`query-results-bypass-primary-key-cache.bad.al`](query-results-bypass-primary-key-cache.bad.al). diff --git a/microsoft/knowledge/performance/reset-clears-partial-record-selection.md b/microsoft/knowledge/performance/reset-clears-partial-record-selection.md index c99f8d2..d53aecb 100644 --- a/microsoft/knowledge/performance/reset-clears-partial-record-selection.md +++ b/microsoft/knowledge/performance/reset-clears-partial-record-selection.md @@ -19,10 +19,10 @@ application-area: [all] Call `Reset` (or empty `SetLoadFields()`) first when the variable must be reused, then call `SetLoadFields` with the fields the next read actually uses, then apply filters and read. After `Reset`, a new `SetLoadFields` is required; the previous list is gone. -See sample: `reset-clears-partial-record-selection.good.al`. +See sample: [`reset-clears-partial-record-selection.good.al`](reset-clears-partial-record-selection.good.al). ## Anti Pattern `SetLoadFields(...)` followed by `Reset()` (or by parameterless `SetLoadFields()`) and then `FindSet` without restoring the load list. The filters look correct; the SQL still selects every column. -See sample: `reset-clears-partial-record-selection.bad.al`. +See sample: [`reset-clears-partial-record-selection.bad.al`](reset-clears-partial-record-selection.bad.al). diff --git a/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md b/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md index 40428ca..ad0bb40 100644 --- a/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md +++ b/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md @@ -26,10 +26,10 @@ Decide `SetCurrentKey` on one question only: **do I need the result set in a spe To make a filtered read fast, ensure a key (index) exists on the table whose leading fields cover the filter, and filter on those fields with `SetRange`/`SetFilter`. That is what lets the optimizer seek. Defining the key creates the index; `SetCurrentKey` is not required to make the optimizer use it. -See sample: `setcurrentkey-sets-sort-order-not-index-hint.good.al`. +See sample: [`setcurrentkey-sets-sort-order-not-index-hint.good.al`](setcurrentkey-sets-sort-order-not-index-hint.good.al). ## Anti Pattern Adding `SetCurrentKey` to a filtered read purely in the belief that it forces SQL Server to seek a particular index, when the code never uses the resulting order. This does nothing for index selection and only appends an `ORDER BY` the query does not need, risking an unnecessary sort. Remove the `SetCurrentKey`; rely on the filters and an existing covering key instead. -See sample: `setcurrentkey-sets-sort-order-not-index-hint.bad.al`. +See sample: [`setcurrentkey-sets-sort-order-not-index-hint.bad.al`](setcurrentkey-sets-sort-order-not-index-hint.bad.al). diff --git a/microsoft/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md b/microsoft/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md index 8077f28..41a0ad8 100644 --- a/microsoft/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md +++ b/microsoft/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md @@ -19,10 +19,10 @@ application-area: [all] Omit `SetLoadFields` on loops whose body performs a documented full-load operation (`Insert`, `Delete`, `Rename`, `TransferFields`, or assignment into a temporary record) on the same record variable, so the initial read already materializes every field those operations need. -See sample: `skip-setloadfields-on-write-and-transferfields.good.al`. +See sample: [`skip-setloadfields-on-write-and-transferfields.good.al`](skip-setloadfields-on-write-and-transferfields.good.al). ## Anti Pattern Calling `SetLoadFields` immediately before a `FindSet` whose body performs `Delete`, `Rename`, `TransferFields`, or copies the record into a temporary table. The review signal is a partial-record setup on a record variable that feeds one of these documented full-load operations in the same iteration. -See sample: `skip-setloadfields-on-write-and-transferfields.bad.al`. +See sample: [`skip-setloadfields-on-write-and-transferfields.bad.al`](skip-setloadfields-on-write-and-transferfields.bad.al). diff --git a/microsoft/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md b/microsoft/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md index 191568e..6a9793f 100644 --- a/microsoft/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md +++ b/microsoft/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md @@ -19,10 +19,10 @@ A `TableRelation` lookup opens the table's `LookupPageId`. If that is the full l Give master tables a slim lookup page (`PageType = List`, few columns, no FactBoxes, no heavy `OnAfterGetRecord`) and assign it to `LookupPageId`. Keep the full list for `DrillDownPageId` and the role-explorer entry. -See sample: `use-dedicated-lookup-pages-not-full-lists.good.al`. +See sample: [`use-dedicated-lookup-pages-not-full-lists.good.al`](use-dedicated-lookup-pages-not-full-lists.good.al). ## Anti Pattern `LookupPageId = Page::"... List"` on a table that already has (or should have) a lookup page. Opening a field lookup then pays list-page cost. The signal is `LookupPageId` pointing at a page that declares FactBoxes or a wide repeater. -See sample: `use-dedicated-lookup-pages-not-full-lists.bad.al`. +See sample: [`use-dedicated-lookup-pages-not-full-lists.bad.al`](use-dedicated-lookup-pages-not-full-lists.bad.al). diff --git a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md index 41ad41f..8c847b4 100644 --- a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md +++ b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md @@ -19,10 +19,10 @@ application-area: [all] Use filtered `DeleteAll(false)` for purpose-built staging or cleanup tables only after verifying that base-table `OnDelete` logic is unnecessary and that trigger code, related subscribers, security filtering, media fields, and companion fields do not add required per-row behavior or regress the bulk path. If deletion requires per-row business logic, keep an explicit triggered operation instead of simulating trigger execution separately. -See sample: `use-deleteall-for-filtered-bulk-deletion.good.al`. +See sample: [`use-deleteall-for-filtered-bulk-deletion.good.al`](use-deleteall-for-filtered-bulk-deletion.good.al). ## Anti Pattern Iterating with `FindSet` + `Delete(false)` to clear a filtered staging batch that has no delete logic or fallback condition. The reverse mistake is assuming `DeleteAll` is always one SQL statement without checking the documented fallback conditions. -See sample: `use-deleteall-for-filtered-bulk-deletion.bad.al`. +See sample: [`use-deleteall-for-filtered-bulk-deletion.bad.al`](use-deleteall-for-filtered-bulk-deletion.bad.al). 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 index 06c0383..e74614b 100644 --- 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 @@ -17,10 +17,10 @@ application-area: [all] 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`. +See sample: [`use-get-instead-of-findfirst-on-full-primary-key.good.al`](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`. +See sample: [`use-get-instead-of-findfirst-on-full-primary-key.bad.al`](use-get-instead-of-findfirst-on-full-primary-key.bad.al). diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-check.md b/microsoft/knowledge/performance/use-isempty-for-existence-check.md index ab574ec..cfa2dc1 100644 --- a/microsoft/knowledge/performance/use-isempty-for-existence-check.md +++ b/microsoft/knowledge/performance/use-isempty-for-existence-check.md @@ -17,10 +17,10 @@ When the caller only needs to know whether any row matches a filter, `IsEmpty()` 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`. +See sample: [`use-isempty-for-existence-check.good.al`](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`. +See sample: [`use-isempty-for-existence-check.bad.al`](use-isempty-for-existence-check.bad.al). diff --git a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md index 0c749ce..b5f9d6a 100644 --- a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md +++ b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md @@ -17,10 +17,10 @@ application-area: [all] Call `SetAutoCalcFields` before `FindSet` when every returned row needs the same FlowField for a comparison, branch, or per-record action. Use `CalcSums` instead when the required result is one aggregate over the filtered set (see `calcsums-instead-of-calcfields-in-loop.md`). -See sample: `use-setautocalcfields-for-per-row-flowfields.good.al`. +See sample: [`use-setautocalcfields-for-per-row-flowfields.good.al`](use-setautocalcfields-for-per-row-flowfields.good.al). ## Anti Pattern Calling `CalcFields` inside the loop when every iteration reads the same FlowField. Each `CalcFields` request requires a separate SQL statement unless a compatible recent result is cached. Do not replace row-specific decisions with `CalcSums`; an aggregate cannot preserve which rows met the condition. -See sample: `use-setautocalcfields-for-per-row-flowfields.bad.al`. +See sample: [`use-setautocalcfields-for-per-row-flowfields.bad.al`](use-setautocalcfields-for-per-row-flowfields.bad.al). diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md index a8d134f..06b2d14 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -17,7 +17,7 @@ application-area: [all] 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. Place the call immediately before the read, after any `SetRange`/`SetFilter`, so a reader can see at a glance which read the selection governs and any projection-changing operation is easy to spot. 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`. +See sample: [`use-setloadfields-for-partial-records.good.al`](use-setloadfields-for-partial-records.good.al). ## Anti Pattern @@ -25,4 +25,4 @@ Loading a wide table and reading one field per row in a loop. The bytes transfer Statement order is not part of this anti pattern. `SetLoadFields` placed ahead of `SetRange`/`SetFilter` materializes exactly the same columns as the reverse order, so a reviewer reports it as a readability observation at most — never as a performance defect. -See sample: `use-setloadfields-for-partial-records.bad.al`. +See sample: [`use-setloadfields-for-partial-records.bad.al`](use-setloadfields-for-partial-records.bad.al). 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 index 6070b76..41f751f 100644 --- a/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md +++ b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md @@ -19,13 +19,13 @@ Reach for `[TryFunction]` when you want to catch a failure without unwinding the 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`. +See sample: [`use-tryfunction-for-error-catching-not-rollback.good.al`](use-tryfunction-for-error-catching-not-rollback.good.al). ## Anti Pattern Wrapping database writes in `[TryFunction]` and expecting successful writes before the error to roll back. They remain, the caller receives `false`, and partially applied state can escape. Defensive sprinkling is also unsafe: every catch overwrites the session error buffer and can hide the failure a later helper intended to inspect. -See sample: `use-tryfunction-for-error-catching-not-rollback.bad.al`. +See sample: [`use-tryfunction-for-error-catching-not-rollback.bad.al`](use-tryfunction-for-error-catching-not-rollback.bad.al). ## See also diff --git a/microsoft/knowledge/performance/validate-on-partial-record-forces-jit.md b/microsoft/knowledge/performance/validate-on-partial-record-forces-jit.md index 00b9657..2b87c2a 100644 --- a/microsoft/knowledge/performance/validate-on-partial-record-forces-jit.md +++ b/microsoft/knowledge/performance/validate-on-partial-record-forces-jit.md @@ -19,10 +19,10 @@ application-area: [all] In a partial-record loop, assign fields directly when trigger side effects are not required. If `Validate` is required, do not use `SetLoadFields` on that iterator, or `AddLoadFields` every field the validate path can touch before the read. -See sample: `validate-on-partial-record-forces-jit.good.al`. +See sample: [`validate-on-partial-record-forces-jit.good.al`](validate-on-partial-record-forces-jit.good.al). ## Anti Pattern `SetLoadFields` on a handful of columns, then `Validate` inside the loop. The load list looks optimal; runtime JIT and TableRelation I/O dominate. The signal is `Validate(` on a record that still has a `SetLoadFields` in the same procedure. -See sample: `validate-on-partial-record-forces-jit.bad.al`. +See sample: [`validate-on-partial-record-forces-jit.bad.al`](validate-on-partial-record-forces-jit.bad.al). diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md index f9245b1..f4a25f3 100644 --- a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md +++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md @@ -17,10 +17,10 @@ Error method trace telemetry includes the AL error string only when the first `E Declare the complete message as a `Label` or `TextConst` and pass it directly to `Error`, followed by substitution values. The client receives the formatted message while telemetry retains the static message template without using the dynamic values as its message. Independently review whether each substitution value is appropriate to show to the current user. -See sample: `avoid-strsubstno-prebuild-before-error.good.al`. +See sample: [`avoid-strsubstno-prebuild-before-error.good.al`](avoid-strsubstno-prebuild-before-error.good.al). ## Anti Pattern `Error(StrSubstNo(CustomerInvalidErr, Customer."No."))` and `Error(HeaderErr + DetailErr)` both make the first argument dynamic. They reduce error telemetry quality; they do not cause that composed string to be logged verbatim as the telemetry message. -See sample: `avoid-strsubstno-prebuild-before-error.bad.al`. +See sample: [`avoid-strsubstno-prebuild-before-error.bad.al`](avoid-strsubstno-prebuild-before-error.bad.al). diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md index b431c12..6bad6f6 100644 --- a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md @@ -17,10 +17,10 @@ application-area: [all] 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. A field that identifies an organization rather than a person — a company registration or VAT registration number — is `OrganizationIdentifiableInformation`, and a financial account identifier such as a bank account number or IBAN is `AccountData`. Choose the classification at field definition time — fixing it later is a schema change. -See sample: `data-classification-required-on-pii-fields.good.al`. +See sample: [`data-classification-required-on-pii-fields.good.al`](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`. +See sample: [`data-classification-required-on-pii-fields.bad.al`](data-classification-required-on-pii-fields.bad.al). diff --git a/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md index ce1b684..d83131f 100644 --- a/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md +++ b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md @@ -17,10 +17,10 @@ Runtime 3.0 (BC 14) provides `ErrorInfo.Message`, `DataClassification`, and `Err Keep `Message` stable and classify its actual content. Choose `ErrorType` for client usability, not as a telemetry privacy boundary. On BC 19 and later, put only support-safe technical context in `DetailedMessage`, because a user can copy it from the dialog. The samples use only members available at the BC 14 article floor. -See sample: `errorinfo-telemetry-classification-and-errortype.good.al`. +See sample: [`errorinfo-telemetry-classification-and-errortype.good.al`](errorinfo-telemetry-classification-and-errortype.good.al). ## Anti Pattern Marking a dynamic customer-bearing `Message` as `SystemMetadata`, or assuming `ErrorType::Internal` keeps it out of telemetry. On BC 19 and later, the same anti-pattern includes placing secrets or personal data in `DetailedMessage` because it is not the primary dialog text. -See sample: `errorinfo-telemetry-classification-and-errortype.bad.al`. +See sample: [`errorinfo-telemetry-classification-and-errortype.bad.al`](errorinfo-telemetry-classification-and-errortype.bad.al). diff --git a/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md index 6d8bfb5..f34804b 100644 --- a/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md +++ b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md @@ -17,10 +17,10 @@ application-area: [all] 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`. +See sample: [`featuretelemetry-customdimensions-no-pii.good.al`](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`. +See sample: [`featuretelemetry-customdimensions-no-pii.bad.al`](featuretelemetry-customdimensions-no-pii.bad.al). diff --git a/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md index 863f3a8..64a9b12 100644 --- a/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md +++ b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md @@ -17,10 +17,10 @@ application-area: [all] Review the dedicated error arguments as telemetry payload. Capture `GetLastErrorText(true)` when scrubbed platform error text is sufficient, and pass `GetLastErrorCallStack()` only as a call stack. Keep custom dimensions non-personal too. -See sample: `featuretelemetry-logerror-implicit-errortext.good.al`. +See sample: [`featuretelemetry-logerror-implicit-errortext.good.al`](featuretelemetry-logerror-implicit-errortext.good.al). ## Anti Pattern Approving a `LogError` call because its explicit dictionary contains only safe values while it passes unsanitized `GetLastErrorText()` or arbitrary context through `ErrorText` or `ErrorCallStack`. Those arguments become telemetry dimensions outside the dictionary. -See sample: `featuretelemetry-logerror-implicit-errortext.bad.al`. +See sample: [`featuretelemetry-logerror-implicit-errortext.bad.al`](featuretelemetry-logerror-implicit-errortext.bad.al). diff --git a/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md index d3a1b7b..659b775 100644 --- a/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md +++ b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md @@ -17,7 +17,7 @@ application-area: [all] 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`. +See sample: [`flowfield-flowfilter-classification-systemmetadata.good.al`](flowfield-flowfilter-classification-systemmetadata.good.al). ## Anti Pattern diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md index 8ff26a8..fd541a1 100644 --- a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md +++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md @@ -17,10 +17,10 @@ Parameterless `GetLastErrorText()` can contain customer content such as field va Use a generic label when the user does not need the underlying detail. If showing unsanitized detail is appropriate, put `%1` in a label and pass parameterless `GetLastErrorText()` as a separate argument. This preserves a useful static telemetry message while keeping the dynamic value out of the telemetry message field. -See sample: `getlasterrortext-customer-content-in-errors.good.al`. +See sample: [`getlasterrortext-customer-content-in-errors.good.al`](getlasterrortext-customer-content-in-errors.good.al). ## Anti Pattern `Error(StrSubstNo(AttachmentFailedErr, GetLastErrorText()))` or `Error(AttachmentPrefixErr + GetLastErrorText())`. Both lose the static first argument and trigger AA0231; neither causes the composed text to be logged verbatim as the Error telemetry message. -See sample: `getlasterrortext-customer-content-in-errors.bad.al`. +See sample: [`getlasterrortext-customer-content-in-errors.bad.al`](getlasterrortext-customer-content-in-errors.bad.al). diff --git a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md index e27d1e4..8192c84 100644 --- a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md +++ b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md @@ -19,7 +19,7 @@ Keep the telemetry message a static, non-personal string ("Customer record proce If a pseudonymous identifier (record `No.`, primary key value) genuinely belongs in the diagnostic, prefer attaching it through a custom dimension and set the call's `DataClassification` to match the data actually shipped — `EndUserPseudonymousIdentifiers` for pseudonymous IDs, `CustomerContent` for content-bearing telemetry. Changing the `DataClassification` alone does **not** make embedding a customer name into the message string acceptable; the data still ships in the message, and downstream consumers still see the literal string. -See sample: `no-pii-in-telemetry-message-string.good.al`. +See sample: [`no-pii-in-telemetry-message-string.good.al`](no-pii-in-telemetry-message-string.good.al). ## Related @@ -30,4 +30,4 @@ See sample: `no-pii-in-telemetry-message-string.good.al`. `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`. +See sample: [`no-pii-in-telemetry-message-string.bad.al`](no-pii-in-telemetry-message-string.bad.al). 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 index d95acef..8c3898d 100644 --- a/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md +++ b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md @@ -17,10 +17,10 @@ Business Central's `Codeunit "Privacy Notice"` creates notices and records per-i Register the custom notice with `CreatePrivacyNotice` during setup or through `OnRegisterPrivacyNotices`. Before sending data, call `ConfirmPrivacyNoticeApproval()` outside a write transaction, or check `GetPrivacyNoticeApprovalState()` when the flow must not show UI. No path should issue the request without approval. -See sample: `privacy-notice-consent-for-external-data-transfer.good.al`. +See sample: [`privacy-notice-consent-for-external-data-transfer.good.al`](privacy-notice-consent-for-external-data-transfer.good.al). ## Anti Pattern A custom integration that posts data without checking its own notice, or that gates the call with a built-in ID such as the Exchange privacy notice ID. Consent for one service does not authorize another. -See sample: `privacy-notice-consent-for-external-data-transfer.bad.al`. +See sample: [`privacy-notice-consent-for-external-data-transfer.bad.al`](privacy-notice-consent-for-external-data-transfer.bad.al). diff --git a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md index 4e76779..a9f12ff 100644 --- a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md +++ b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md @@ -17,7 +17,7 @@ The current extension point is `Codeunit "Privacy Notice"`. Extensions can subsc Choose a stable ID owned by the extension. Register it through `OnRegisterPrivacyNotices`, or call `PrivacyNotice.CreatePrivacyNotice` during an intentional setup or upgrade path. Use that same ID for consent checks described in `privacy-notice-consent-for-external-data-transfer.md`. -See sample: `register-integration-in-privacy-notice-registrations.good.al`. +See sample: [`register-integration-in-privacy-notice-registrations.good.al`](register-integration-in-privacy-notice-registrations.good.al). ## Anti Pattern diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md index 67381f7..2febcae 100644 --- a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md +++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md @@ -17,10 +17,10 @@ application-area: [all] 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`. +See sample: [`session-logmessage-requires-dataclassification.good.al`](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`. +See sample: [`session-logmessage-requires-dataclassification.bad.al`](session-logmessage-requires-dataclassification.bad.al). diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md index 253d2cc..f41dc11 100644 --- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md @@ -17,7 +17,7 @@ A valid table-level `DataClassification` is the effective default for the Normal Use a table-level classification when it accurately describes the table's fields, and add a field-level classification only where a field stores a different kind of data. Do not flag a Normal field solely because it omits an explicit property when its own table supplies a valid default; verify whether the inherited value matches the field's data instead. A `tableextension` has no default to inherit, so require an explicit `DataClassification` on every Normal field it adds. -See sample: `table-level-data-classification-cascades.good.al`. +See sample: [`table-level-data-classification-cascades.good.al`](table-level-data-classification-cascades.good.al). ## Anti Pattern diff --git a/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md index 4bc8816..0d7ca47 100644 --- a/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md +++ b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md @@ -17,10 +17,10 @@ Calling `Open()` on an already open query first closes the current dataset and o Open once for one read pass. Close after the pass, and call `Clear(QueryVariable)` before reusing the variable for a logically independent query whose filters must start empty. Set the next pass's filters explicitly before reopening. -See sample: `reopening-query-resets-cursor-but-keeps-filters.good.al`. +See sample: [`reopening-query-resets-cursor-but-keeps-filters.good.al`](reopening-query-resets-cursor-but-keeps-filters.good.al). ## Anti Pattern Calling `Open()` inside or between reads to "advance" or "start fresh", or reusing the same query variable for a new operation while assuming `Open()` cleared old filters. The code compiles but can repeatedly process the first row or silently omit rows behind a retained filter. -See sample: `reopening-query-resets-cursor-but-keeps-filters.bad.al`. +See sample: [`reopening-query-resets-cursor-but-keeps-filters.bad.al`](reopening-query-resets-cursor-but-keeps-filters.bad.al). diff --git a/microsoft/knowledge/query/set-query-filters-before-open.md b/microsoft/knowledge/query/set-query-filters-before-open.md index f999456..bb82e9d 100644 --- a/microsoft/knowledge/query/set-query-filters-before-open.md +++ b/microsoft/knowledge/query/set-query-filters-before-open.md @@ -17,10 +17,10 @@ application-area: [all] Apply every filter before `Open()`, then read the dataset to completion and call `Close()`. When a later branch needs different filters, close or clear the query, set the new filters, and open a new dataset deliberately. -See sample: `set-query-filters-before-open.good.al`. +See sample: [`set-query-filters-before-open.good.al`](set-query-filters-before-open.good.al). ## Anti Pattern `Query.Open()` followed by `SetFilter` or `SetRange` and then `Read()` under the assumption that the filter updates the open cursor. Refiltering after `Open()` is valid only when the code intentionally opens a fresh dataset afterward. -See sample: `set-query-filters-before-open.bad.al`. +See sample: [`set-query-filters-before-open.bad.al`](set-query-filters-before-open.bad.al). diff --git a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md index 7441712..649c384 100644 --- a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md +++ b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md @@ -15,8 +15,8 @@ AL does not ship a built-in `HtmlEncode` (or equivalent) function. Code that bui ## 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`. +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`](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 (`<`, `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 (`<`, `` column headers, so a captionless field that is mean 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`. +See sample: [`standalone-content-in-layout-table.good.al`](standalone-content-in-layout-table.good.al). diff --git a/microsoft/knowledge/ui/style-expr-text-vs-boolean.md b/microsoft/knowledge/ui/style-expr-text-vs-boolean.md index 5040099..720423b 100644 --- a/microsoft/knowledge/ui/style-expr-text-vs-boolean.md +++ b/microsoft/knowledge/ui/style-expr-text-vs-boolean.md @@ -22,4 +22,4 @@ When `StyleExpr` is Text, you must trace the variable's assignments — typicall 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`. +See sample: [`style-expr-text-vs-boolean.good.al`](style-expr-text-vs-boolean.good.al). diff --git a/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md index b56aadb..c02bbd9 100644 --- a/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md +++ b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md @@ -24,4 +24,4 @@ Both manifestations have the same root cause: tabular semantics were intended bu 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`. +See sample: [`tabular-intent-requires-data-table-conditions.bad.al`](tabular-intent-requires-data-table-conditions.bad.al). diff --git a/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md index 75c8a09..d796827 100644 --- a/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md +++ b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md @@ -17,13 +17,13 @@ application-area: [all] ## Best Practice -Put the validation in one local procedure and call it from both places: from the request page's `OnQueryClosePage`, so an interactive user can correct the input where they entered it, and from `OnPreReport` (or the relevant `OnPreDataItem`), so a run without a request page is still refused. Guard the interactive call on the close action — validate only when the user confirmed the run, for example `if CloseAction = Action::OK then`. The base application uses this shape; report 292, `Copy Sales Document`, validates its request-page input in `OnQueryClosePage` behind a close-action check. Mark the control with `ShowMandatory` as well, so the requirement is visible before the user submits — see `showmandatory-on-code-required-page-fields.md`. See sample: `validate-request-page-input-in-onqueryclosepage.good.al`. +Put the validation in one local procedure and call it from both places: from the request page's `OnQueryClosePage`, so an interactive user can correct the input where they entered it, and from `OnPreReport` (or the relevant `OnPreDataItem`), so a run without a request page is still refused. Guard the interactive call on the close action — validate only when the user confirmed the run, for example `if CloseAction = Action::OK then`. The base application uses this shape; report 292, `Copy Sales Document`, validates its request-page input in `OnQueryClosePage` behind a close-action check. Mark the control with `ShowMandatory` as well, so the requirement is visible before the user submits — see `showmandatory-on-code-required-page-fields.md`. See sample: [`validate-request-page-input-in-onqueryclosepage.good.al`](validate-request-page-input-in-onqueryclosepage.good.al). ## Anti Pattern Validating mandatory request-page input only in `OnPreReport`. The check is correct and the report is never run with bad input, but every interactive mistake costs the user the whole request page: the error arrives after the page is gone, and filters, dates, and options all have to be entered again. Reviewer signal: a `TestField`, `Error`, or blank/zero-value check in `OnPreReport` or `OnPreDataItem` against a variable that is bound to a request-page control, in a report whose request page declares no `OnQueryClosePage`. -The mirror defect is an `OnQueryClosePage` that validates without inspecting `CloseAction`: because an error prevents the page from closing, a user who presses Cancel or Esc to abandon the report is trapped in a request page that errors on every attempt to leave it. Validating only in `OnQueryClosePage` is the third variant — the interactive path behaves well, and a job queue entry runs the report with unchecked input. See sample: `validate-request-page-input-in-onqueryclosepage.bad.al`. +The mirror defect is an `OnQueryClosePage` that validates without inspecting `CloseAction`: because an error prevents the page from closing, a user who presses Cancel or Esc to abandon the report is trapped in a request page that errors on every attempt to leave it. Validating only in `OnQueryClosePage` is the third variant — the interactive path behaves well, and a job queue entry runs the report with unchecked input. See sample: [`validate-request-page-input-in-onqueryclosepage.bad.al`](validate-request-page-input-in-onqueryclosepage.bad.al). ## See also 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 index 9ba7e8a..5efc751 100644 --- a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md +++ b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md @@ -17,10 +17,10 @@ Primary-key changes and field-type changes (for example widening `Integer` to `B 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`. +See sample: [`breaking-changes-only-on-tables-without-data.good.al`](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`. +See sample: [`breaking-changes-only-on-tables-without-data.bad.al`](breaking-changes-only-on-tables-without-data.bad.al). diff --git a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md index 30f6ca7..8770f93 100644 --- a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md +++ b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md @@ -17,10 +17,10 @@ application-area: [all] Have check triggers call query-only helpers that raise an error when an invariant fails. Put every `Insert`, `Modify`, `Delete`, `Rename`, `DataTransfer`, and other migration write behind helpers called from the matching `OnUpgrade...` trigger. -See sample: `check-only-triggers-do-not-migrate-data.good.al`. +See sample: [`check-only-triggers-do-not-migrate-data.good.al`](check-only-triggers-do-not-migrate-data.good.al). ## Anti Pattern Repairing data in `OnCheckPreconditions...` or finishing migration in `OnValidateUpgrade...`. Those writes blur the phase contract and make a check alter the state it is supposed to assess. -See sample: `check-only-triggers-do-not-migrate-data.bad.al`. +See sample: [`check-only-triggers-do-not-migrate-data.bad.al`](check-only-triggers-do-not-migrate-data.bad.al). diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md index 988dfa4..830dbdb 100644 --- a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md +++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md @@ -17,13 +17,13 @@ Tables that can contain more than 300,000 records, and any newly added field on 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`. +See sample: [`datatransfer-for-bulk-init.good.al`](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 sample: [`datatransfer-for-bulk-init.bad.al`](datatransfer-for-bulk-init.bad.al). ## See also diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md index 34a406b..33173da 100644 --- a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md @@ -19,10 +19,10 @@ For *new fields and tables added in the same change* this is fine: nothing yet d Use `DataTransfer` when set-based transfer is safe and row-level business logic is intentionally unnecessary — initial population of a new field is the canonical case. When an existing field's validation must run, loop through records and call `Validate(Field, Value)`; if the table's modify trigger must also run, follow with `Modify(true)`. If performance requires `DataTransfer`, document exactly which field-validation and row-modification triggers or subscribers are intentionally bypassed and verify that derived data remains correct. -See sample: `datatransfer-skips-triggers-and-subscribers.good.al`. +See sample: [`datatransfer-skips-triggers-and-subscribers.good.al`](datatransfer-skips-triggers-and-subscribers.good.al). ## Anti Pattern Reaching for `DataTransfer` to update an existing field with non-trivial `OnValidate` or `OnModify` logic, without confirming that both validation and row-modification subscribers can be skipped. Replacing it with only `Modify(true)` is also incomplete when field validation is required; call `Validate` for that field first. -See sample: `datatransfer-skips-triggers-and-subscribers.bad.al`. +See sample: [`datatransfer-skips-triggers-and-subscribers.bad.al`](datatransfer-skips-triggers-and-subscribers.bad.al). 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 index cf585eb..868b61e 100644 --- a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md +++ b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md @@ -17,10 +17,10 @@ When upgrade code encounters unexpected data — a record it expected to find, a 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`. +See sample: [`do-not-block-upgrade-on-data-errors.good.al`](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`. +See sample: [`do-not-block-upgrade-on-data-errors.bad.al`](do-not-block-upgrade-on-data-errors.bad.al). diff --git a/microsoft/knowledge/upgrade/enum-values-additive-at-end.md b/microsoft/knowledge/upgrade/enum-values-additive-at-end.md index 4de929b..332efa6 100644 --- a/microsoft/knowledge/upgrade/enum-values-additive-at-end.md +++ b/microsoft/knowledge/upgrade/enum-values-additive-at-end.md @@ -17,13 +17,13 @@ An AL `enum` is a fixed list of ordinal-named values. Persisted rows reference e 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`. +See sample: [`enum-values-additive-at-end.good.al`](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 sample: [`enum-values-additive-at-end.bad.al`](enum-values-additive-at-end.bad.al). ## See also diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md index 6324836..5cbdec8 100644 --- a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md +++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md @@ -17,13 +17,13 @@ On the first install of an extension on a tenant the platform records a zero dat In `OnInstallAppPerCompany`, fetch the current `ModuleInfo` via `NavApp.GetCurrentModuleInfo`, compare `AppInfo.DataVersion()` to `Version.Create('0.0.0.0')`, and run first-install seed logic only when they match. On a non-zero data version, follow the reinstall path or exit. -See sample: `first-install-dataversion-zero-check.good.al`. +See sample: [`first-install-dataversion-zero-check.good.al`](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, first-install seed code can run again and duplicate rows. -See sample: `first-install-dataversion-zero-check.bad.al`. +See sample: [`first-install-dataversion-zero-check.bad.al`](first-install-dataversion-zero-check.bad.al). ## See also diff --git a/microsoft/knowledge/upgrade/guard-database-reads.md b/microsoft/knowledge/upgrade/guard-database-reads.md index c5bc206..09a99e3 100644 --- a/microsoft/knowledge/upgrade/guard-database-reads.md +++ b/microsoft/knowledge/upgrade/guard-database-reads.md @@ -17,10 +17,10 @@ Inside an upgrade codeunit (or any procedure transitively invoked from `OnUpgrad 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`. +See sample: [`guard-database-reads.good.al`](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`. +See sample: [`guard-database-reads.bad.al`](guard-database-reads.bad.al). diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md index 4733ef2..bfa0f03 100644 --- a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md +++ b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md @@ -23,10 +23,10 @@ Several legitimate cases do NOT need upgrade code: 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`. +See sample: [`initvalue-does-not-update-existing-rows.good.al`](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`. +See sample: [`initvalue-does-not-update-existing-rows.bad.al`](initvalue-does-not-update-existing-rows.bad.al). diff --git a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md index 12f432f..52c3989 100644 --- a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md +++ b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md @@ -17,10 +17,10 @@ An install codeunit runs when an extension is installed for the first time or an Use `Subtype = Install` for first-install and reinstall initialization. Put version migration in a separate `Subtype = Upgrade` codeunit and enter it from `OnUpgradePerCompany` or `OnUpgradePerDatabase`. -See sample: `install-code-does-not-run-on-version-upgrade.good.al`. +See sample: [`install-code-does-not-run-on-version-upgrade.good.al`](install-code-does-not-run-on-version-upgrade.good.al). ## Anti Pattern Putting a schema or data migration only in an install trigger and expecting it to run when a higher app version is upgraded. The migration is never invoked on that path. -See sample: `install-code-does-not-run-on-version-upgrade.bad.al`. +See sample: [`install-code-does-not-run-on-version-upgrade.bad.al`](install-code-does-not-run-on-version-upgrade.bad.al). diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md index b9e5e13..3095655 100644 --- a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md +++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md @@ -17,10 +17,10 @@ Triggers such as `OnValidateUpgradePerCompany` run on every upgrade pass. A full Filter directly to invalid rows and use `IsEmpty` or another bounded existence check where possible. If a broad validation is unavoidable, document the invariant that requires it and keep all data changes in `OnUpgrade...`. -See sample: `minimize-onvalidate-upgrade-triggers.good.al`. +See sample: [`minimize-onvalidate-upgrade-triggers.good.al`](minimize-onvalidate-upgrade-triggers.good.al). ## Anti Pattern Reading every record in `OnValidateUpgradePerCompany` when a filtered existence check can prove the same invariant. The scan repeats on every upgrade. -See sample: `minimize-onvalidate-upgrade-triggers.bad.al`. +See sample: [`minimize-onvalidate-upgrade-triggers.bad.al`](minimize-onvalidate-upgrade-triggers.bad.al). diff --git a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md index eb644b6..f04fb40 100644 --- a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md +++ b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md @@ -19,10 +19,10 @@ The rule applies inside any codeunit with `Subtype = Upgrade` and to any procedu 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`. +See sample: [`no-external-calls-in-upgrade.good.al`](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`. +See sample: [`no-external-calls-in-upgrade.bad.al`](no-external-calls-in-upgrade.bad.al). diff --git a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md index cb008ac..64a54f2 100644 --- a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md +++ b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md @@ -17,10 +17,10 @@ application-area: [all] 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`. +See sample: [`obsolete-pending-to-removed-staging.good.al`](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`. +See sample: [`obsolete-pending-to-removed-staging.bad.al`](obsolete-pending-to-removed-staging.bad.al). diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md index d6ec37a..b468437 100644 --- a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md +++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md @@ -22,13 +22,13 @@ In both forms, the reason should name the replacement and the tag should identif For an object or field, set all three properties together. For a method, variable, or event, provide both `[Obsolete]` arguments. Keep the original tag stable through the lifecycle rather than changing it to a planned removal version. -See sample: `obsoletion-requires-reason-and-tag.good.al`. +See sample: [`obsoletion-requires-reason-and-tag.good.al`](obsoletion-requires-reason-and-tag.good.al). ## Anti Pattern Setting only `ObsoleteState = Pending`/`Removed` on an object or field, or using `[Obsolete('', '')]` on a method, variable, or event. Both forms produce deprecation metadata without useful replacement guidance or traceability. -See sample: `obsoletion-requires-reason-and-tag.bad.al`. +See sample: [`obsoletion-requires-reason-and-tag.bad.al`](obsoletion-requires-reason-and-tag.bad.al). ## See also diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md index e5e1983..0bba7c2 100644 --- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md @@ -19,10 +19,10 @@ Registration is not install-time seeding. When an extension is installed into an In the upgrade codeunit, guard work with `HasUpgradeTag` and call `SetUpgradeTag` only after successful completion. Seed the same tag explicitly from `OnInstallAppPerCompany` when first-install logic should not run as a later upgrade. Also add historical per-company tags to `OnGetPerCompanyUpgradeTags` so `SetAllUpgradeTags` marks them complete for newly created companies. Keep the tag definition shared so all paths use the exact same value. -See sample: `register-upgrade-tags-with-subscribers.good.al`. +See sample: [`register-upgrade-tags-with-subscribers.good.al`](register-upgrade-tags-with-subscribers.good.al). ## Anti Pattern Assuming an `OnGetPerCompanyUpgradeTags` subscriber sets tags during extension installation, or omitting the subscriber and allowing old upgrade steps to run when `SetAllUpgradeTags` initializes a new company. The subscriber supplies a list; only `SetAllUpgradeTags` or an explicit `SetUpgradeTag` call persists it. -See sample: `register-upgrade-tags-with-subscribers.bad.al`. +See sample: [`register-upgrade-tags-with-subscribers.bad.al`](register-upgrade-tags-with-subscribers.bad.al). diff --git a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md index 0b441e6..c4558b7 100644 --- a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md +++ b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md @@ -19,10 +19,10 @@ This is the opposite of a load-bearing concern: code that MUST run during the up 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`. +See sample: [`skip-nonessential-work-via-execution-context.good.al`](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`. +See sample: [`skip-nonessential-work-via-execution-context.bad.al`](skip-nonessential-work-via-execution-context.bad.al). diff --git a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md index dcc21e3..078e109 100644 --- a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md +++ b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md @@ -19,10 +19,10 @@ Empty `OnUpgradePerCompany` / `OnUpgradePerDatabase` triggers are acceptable — 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`. +See sample: [`triggers-call-helpers-not-implementations.good.al`](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`. +See sample: [`triggers-call-helpers-not-implementations.bad.al`](triggers-call-helpers-not-implementations.bad.al). diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md index a9fee7c..8bf558d 100644 --- a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md +++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md @@ -17,10 +17,10 @@ A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upg 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`. +See sample: [`upgrade-codeunit-subtype.good.al`](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`. +See sample: [`upgrade-codeunit-subtype.bad.al`](upgrade-codeunit-subtype.bad.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 62347d1..bb7649f 100644 --- a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md @@ -17,13 +17,13 @@ Each piece of upgrade logic must run exactly once per company (or database) acro 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`. +See sample: [`use-upgrade-tags-not-version-checks.good.al`](use-upgrade-tags-not-version-checks.good.al). ## Anti Pattern 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 sample: [`use-upgrade-tags-not-version-checks.bad.al`](use-upgrade-tags-not-version-checks.bad.al). ## See also diff --git a/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md index 7988326..66fe36e 100644 --- a/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md +++ b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md @@ -21,7 +21,7 @@ LLMs treat one carrier as universal. Some assume the caption is serialised and r Establish which schema versions the field is served under before changing anything about its enum. Under schema 2.0 (Microsoft's API v2.0, an explicit `$schemaversion=2.0` in the consumer contract, or another reliable context signal) the member name is the contract: keep names stable, put wording changes in `Caption`, add a value by appending a new name with an ordinal above every existing one, and retire a value through `ObsoleteState` rather than by deleting it. For a custom API that clients may still call as schema 1.0, any install of BC 17 to 23 or a caller that pins 1.0, the caption is a contract as well: change neither name nor caption in place, or publish the change as a new `APIVersion` on a new page object. A rename is out in every case: AppSourceCop AS0082 rejects it against a baseline, and dependent extensions bind to the name. -See sample: `api-enum-values-are-a-contract-by-name-not-ordinal.good.al`. +See sample: [`api-enum-values-are-a-contract-by-name-not-ordinal.good.al`](api-enum-values-are-a-contract-by-name-not-ordinal.good.al). ## Anti Pattern @@ -31,7 +31,7 @@ Detection signal: a diff hunk that changes the name in a `value(...)` line while The mirror image is a review defect: suppressing a caption-change finding because "the API serialises names". That holds only under schema 2.0. Do not flag a `Caption` change when the reviewer can establish schema 2.0 for every consumer; on a custom API where clients may select schema 1.0, report a caption change on an exposed value as a consumer-visible change and ask for versioning. A value appended at the end changes no contract under either schema and is never a finding. -See sample: `api-enum-values-are-a-contract-by-name-not-ordinal.bad.al`. +See sample: [`api-enum-values-are-a-contract-by-name-not-ordinal.bad.al`](api-enum-values-are-a-contract-by-name-not-ordinal.bad.al). ## See also diff --git a/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md b/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md index 2358a6b..8f6a73c 100644 --- a/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md +++ b/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md @@ -17,10 +17,10 @@ An API meant purely for reading — a reporting or lookup endpoint — is not re For a read-only / reporting API page set all three CRUD guards off — `InsertAllowed = false`, `ModifyAllowed = false`, `DeleteAllowed = false` — and mark the page `Editable = false`. The endpoint then serves GET requests and rejects any insert, modify, or delete, matching the read-only contract regardless of the caller. Make the read-only stance explicit rather than depending on the writable default. -See sample: `disable-write-operations-on-read-only-api-pages.good.al`. +See sample: [`disable-write-operations-on-read-only-api-pages.good.al`](disable-write-operations-on-read-only-api-pages.good.al). ## Anti Pattern An API intended for read-only consumption that omits the CRUD guards, leaving `InsertAllowed`, `ModifyAllowed`, and `DeleteAllowed` at their writable defaults. The endpoint silently accepts POST, PATCH, and DELETE, so a client can mutate or remove data the API was never meant to expose for writing. The detection signal: a read-only/reporting `PageType = API` page that does not set the three `*Allowed = false` properties. -See sample: `disable-write-operations-on-read-only-api-pages.bad.al`. +See sample: [`disable-write-operations-on-read-only-api-pages.bad.al`](disable-write-operations-on-read-only-api-pages.bad.al). diff --git a/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md b/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md index 739b5aa..4337b41 100644 --- a/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md +++ b/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md @@ -17,10 +17,10 @@ This is about the data-consistency contract of an API endpoint: what a consumer For an API page that must expose only committed data, set the endpoint's read isolation once as the page opens: in the `OnOpenPage` trigger write `Rec.ReadIsolation := IsolationLevel::ReadCommitted;`. Every read the endpoint then serves ignores uncommitted writes from concurrent transactions, so a consumer never receives a row that another transaction might still roll back. -See sample: `expose-only-committed-data-from-api-reads.good.al`. +See sample: [`expose-only-committed-data-from-api-reads.good.al`](expose-only-committed-data-from-api-reads.good.al). ## Anti Pattern An API intended to return committed-only data that sets no isolation level, leaving reads at the default that can observe in-flight, uncommitted writes. A consumer can fetch a row created by a concurrent transaction that is later rolled back — a dirty read that surfaces data which never durably existed. The detection signal: a committed-only read API with no `Rec.ReadIsolation := IsolationLevel::ReadCommitted` in `OnOpenPage`. -See sample: `expose-only-committed-data-from-api-reads.bad.al`. +See sample: [`expose-only-committed-data-from-api-reads.bad.al`](expose-only-committed-data-from-api-reads.bad.al). diff --git a/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md b/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md index 7f0ed87..18908a7 100644 --- a/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md +++ b/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md @@ -17,10 +17,10 @@ An API consumer that needs to *do* something to a record — post it, ship it, r Declare the operation as `[ServiceEnabled] procedure Post(var ActionContext: WebServiceActionContext)` on the API page. Inside, perform the operation against `Rec`, then call a `SetActionResponse` helper that writes the result — the bound record and its id — back into the `WebServiceActionContext` so the caller receives a well-formed response. The operation is now an explicit, named endpoint action separate from ordinary field writes. -See sample: `expose-operations-as-bound-actions.good.al`. +See sample: [`expose-operations-as-bound-actions.good.al`](expose-operations-as-bound-actions.good.al). ## Anti Pattern Exposing a writable Boolean (for example `posted`) whose `OnValidate` performs the posting. A client that PATCHes the field to `true` — an action indistinguishable from any other data edit — silently triggers a side-effecting business operation. The detection signal: an API page field whose `OnValidate` posts, ships, or releases, instead of a `[ServiceEnabled]` bound action. -See sample: `expose-operations-as-bound-actions.bad.al`. +See sample: [`expose-operations-as-bound-actions.bad.al`](expose-operations-as-bound-actions.bad.al). diff --git a/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md b/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md index 93d2dcd..f34e9a4 100644 --- a/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md +++ b/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md @@ -17,10 +17,10 @@ Every BC table carries a `SystemId` — an immutable GUID assigned at insert and Set `ODataKeyFields = SystemId` so OData routes records by the stable GUID, and expose it as `field(id; Rec.SystemId)` marked `Editable = false`. Clients then address a record at `.../customers()`, an identity that survives any rename of the business key. Keep the business key (for example `No.`) as an ordinary exposed field, not as the OData key. -See sample: `expose-systemid-as-the-api-key.good.al`. +See sample: [`expose-systemid-as-the-api-key.good.al`](expose-systemid-as-the-api-key.good.al). ## Anti Pattern Setting `ODataKeyFields = "No."` so the endpoint addresses records by a renamable business field. As soon as a user changes that `No.`, every external reference built on the old value points at nothing, silently breaking integrations. The detection signal: `ODataKeyFields` set to a business field rather than `SystemId`, or an API page that exposes no `id` field bound to `Rec.SystemId`. -See sample: `expose-systemid-as-the-api-key.bad.al`. +See sample: [`expose-systemid-as-the-api-key.bad.al`](expose-systemid-as-the-api-key.bad.al). diff --git a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md index 4cf81d6..2f92c0c 100644 --- a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md +++ b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md @@ -17,13 +17,13 @@ application-area: [all] Define the child foreign key as `Guid` with a `TableRelation` to the parent table's `SystemId`, then use `SubPageLink = "" = Field(SystemId)` on the parent API page. A child collection may omit `Multiplicity` and rely on the default 1:N relationship, or declare `Multiplicity = Many` explicitly. Set `Multiplicity = ZeroOrOne` when the intended navigation metadata is a singleton. -See sample: `link-api-parts-on-systemid-and-set-multiplicity.good.al`. +See sample: [`link-api-parts-on-systemid-and-set-multiplicity.good.al`](link-api-parts-on-systemid-and-set-multiplicity.good.al). ## Anti Pattern On a parent API with `ODataKeyFields = SystemId`, linking a child business field such as `"Order No."` to the parent's `"No."` creates a second identity scheme for navigation instead of using the contract's stable GUID. A separate defect is an explicit `Multiplicity` that conflicts with the intended shape, such as `ZeroOrOne` on an order-lines collection or `Many` on a singleton. Do not treat omission alone as a defect: it is valid for a collection because the default is 1:N, while an intended singleton must explicitly use `Multiplicity = ZeroOrOne`. -See sample: `link-api-parts-on-systemid-and-set-multiplicity.bad.al`. +See sample: [`link-api-parts-on-systemid-and-set-multiplicity.bad.al`](link-api-parts-on-systemid-and-set-multiplicity.bad.al). ## Source diff --git a/microsoft/knowledge/web-services/set-required-api-page-properties.md b/microsoft/knowledge/web-services/set-required-api-page-properties.md index 496db1a..24edc5d 100644 --- a/microsoft/knowledge/web-services/set-required-api-page-properties.md +++ b/microsoft/knowledge/web-services/set-required-api-page-properties.md @@ -17,10 +17,10 @@ An API page needs `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, and Declare the five routing/entity properties required by the API page and set `APIVersion` explicitly for a stable published contract, for example `'v1.0'`. Expose the record's fields inside a repeater under `area(content)`. Review missing routing metadata as a malformed API definition, but review a missing `APIVersion` as unintended publication under `beta`, not as an unpublished endpoint. -See sample: `set-required-api-page-properties.good.al`. +See sample: [`set-required-api-page-properties.good.al`](set-required-api-page-properties.good.al). ## Anti Pattern Leaving out `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, or `SourceTable` leaves the API definition incomplete. A subtler contract defect is declaring all of those but omitting `APIVersion`: the page is exposed as `beta`, which is valid runtime behavior but not the explicit stable route a production client expects. -See sample: `set-required-api-page-properties.bad.al`. +See sample: [`set-required-api-page-properties.bad.al`](set-required-api-page-properties.bad.al). diff --git a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md index bfd2c9f..1e8417b 100644 --- a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md +++ b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md @@ -17,10 +17,10 @@ Once an API version is published, external clients depend on its exact shape — Keep the existing page object and its `APIVersion = 'v1.0'` contract unchanged. Copy the page to a new object ID, set that object's `APIVersion = 'v2.0'`, and make the v2-only shape changes there. A multi-value `APIVersion` list is appropriate only when the exact same page shape is supported under each listed version. -See sample: `version-apis-by-adding-not-mutating-published-versions.good.al`. +See sample: [`version-apis-by-adding-not-mutating-published-versions.good.al`](version-apis-by-adding-not-mutating-published-versions.good.al). ## Anti Pattern Editing the published `v1.0` page in place breaks its clients. So does adding `v2.0` to that same page and assuming subsequent field changes apply only to v2: both routes use one object shape. The detection signal is a breaking shape change without a separate API page object retaining the old version. -See sample: `version-apis-by-adding-not-mutating-published-versions.bad.al`. +See sample: [`version-apis-by-adding-not-mutating-published-versions.bad.al`](version-apis-by-adding-not-mutating-published-versions.bad.al). diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md index b35a05c..2aad620 100644 --- a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md +++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md @@ -17,13 +17,13 @@ Business Central can subscribe only to eligible API pages, not every endpoint th Before creating a subscription, confirm the resource appears in `webhookSupportedResources` and that a custom endpoint is an API page with a single stable key over an eligible persistent table. Use one validation path that echoes `validationToken` for both create (`POST`) and renew (`PATCH`) handshakes. Track `expirationDateTime` and renew before expiry: online subscriptions expire after three days, while on-premises lifetime defaults to three days and can be changed with `ApiSubscriptionExpiration`. -See samples: `webhook-eligibility-and-validationtoken-renewal.good.al` and `webhook-eligibility-and-validationtoken-renewal.good.js`. +See samples: [`webhook-eligibility-and-validationtoken-renewal.good.al`](webhook-eligibility-and-validationtoken-renewal.good.al) and [`webhook-eligibility-and-validationtoken-renewal.good.js`](webhook-eligibility-and-validationtoken-renewal.good.js). ## Anti Pattern Attempting to subscribe to an API query, temporary/composite/system-table/Job Queue Entry API page, or assuming a successful create handshake makes renewal automatic. Composite includes an explicit multi-field `ODataKeyFields` and a missing `ODataKeyFields` when the source table's primary key has multiple fields. A renewal issues the same validation challenge; a notification handler that ignores the query-string token cannot create or renew the subscription. -See samples: `webhook-eligibility-and-validationtoken-renewal.bad.al` and `webhook-eligibility-and-validationtoken-renewal.bad.js`. +See samples: [`webhook-eligibility-and-validationtoken-renewal.bad.al`](webhook-eligibility-and-validationtoken-renewal.bad.al) and [`webhook-eligibility-and-validationtoken-renewal.bad.js`](webhook-eligibility-and-validationtoken-renewal.bad.js). ## Source diff --git a/microsoft/skills/review/al-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md index 767c9af..1ddd810 100644 --- a/microsoft/skills/review/al-breaking-changes-review.md +++ b/microsoft/skills/review/al-breaking-changes-review.md @@ -134,7 +134,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s } ``` -The empty-corpus case — BCQuality's state until breaking-changes knowledge files land — produces: +When no applicable breaking-changes knowledge is available, the report is: ```json { diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 41f0f78..9972aca 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -300,7 +300,9 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip } ``` -The empty-corpus case — BCQuality's state until knowledge files land — rolls up to `no-knowledge`: +When the selected leaves find no applicable knowledge, the result rolls up to +`no-knowledge`. This example shows two leaf results; a full run includes every +invoked leaf: ```json { diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index bc4598a..63c4d5e 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-review.md @@ -132,7 +132,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s } ``` -The empty-corpus case — BCQuality's state until error-handling knowledge files land — produces: +When no applicable error-handling knowledge is available, the report is: ```json { diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index 7248a45..559d036 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-review.md @@ -141,7 +141,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s } ``` -The empty-corpus case — BCQuality's state until events knowledge files land — produces: +When no applicable events knowledge is available, the report is: ```json { diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index d4738ac..f2fa80d 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -134,7 +134,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s } ``` -The empty-corpus case — BCQuality's state until performance knowledge files land — produces: +When no applicable performance knowledge is available, the report is: ```json { diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index 5d998c9..e3e4049 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -129,7 +129,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s } ``` -The empty-corpus case — BCQuality's state until security knowledge files land — produces: +When no applicable security knowledge is available, the report is: ```json { diff --git a/skills/do.md b/skills/do.md index 5234437..4ac433b 100644 --- a/skills/do.md +++ b/skills/do.md @@ -19,7 +19,12 @@ An action skill is a single markdown file with YAML frontmatter. It lives inside - `/community/skills/` — community-contributed action skills. - `/custom/skills/` — partner or customer action skills (typically in a consumer repo, not in BCQuality itself). -Action skills do not live at the repo root. The files in `/skills/` — the three meta-skill contracts (READ, DO, WRITE) and the entry-point skill (`entry.md`, `kind: entry-point`) — are the only skills that sit outside a layer. The entry-point skill structurally follows this same four-step pattern but produces a dispatch record rather than a findings-report; see `skills/entry.md` for its contract. +Action skills do not live at the repo root. Layer-independent files in +`/skills/` contain the three meta-skill contracts (READ, DO, WRITE), the +entry-point skill (`entry.md`, `kind: entry-point`), and host-format adapters. +Adapters are not action skills. Entry structurally follows the same +four-step pattern but produces a dispatch record rather than a findings-report; +see [entry.md](entry.md) for its contract. ## Skills hold mechanics; knowledge files hold BC facts @@ -319,15 +324,16 @@ A super-skill's top-level `suppressed[]` remains knowledge-file-only and is typi ## Worked example -A minimal action skill that cites applicable guidance for a changed AL file, without generating findings of its own: +A minimal action skill that reviews a changed AL file against applicable +guidance. Relevance alone never produces a finding: ```yaml --- kind: action-skill -id: cite-applicable-guidance +id: review-applicable-guidance version: 1 -title: Cite applicable guidance -description: Lists knowledge files relevant to a changed AL file. +title: Review applicable guidance +description: Reviews a changed AL file against applicable knowledge. inputs: [file-path] outputs: [findings-report] technologies: [al] @@ -345,7 +351,12 @@ Filter by `technologies: [al]` and `bc-version` matching the target environment. Intersect `keywords` with tokens derived from the target file's object name and changed members. ## Action -For each worklist entry, emit one finding with severity `info`, a message naming the concern, and a reference object pointing to the knowledge file. +Read each worklisted article in full and compare its normative guidance to the +input. Emit a finding only for a concrete violation or an observation the +article explicitly defines, with justified severity, evidence, and a reference +copied from the discovered article path. Do not report an article merely +because it was relevant. If every item was evaluated and none warrants a +finding, return `completed` with an empty `findings` array. ## Output Conforms to the DO output contract. diff --git a/skills/read.md b/skills/read.md index 6a2080d..f2e9c1e 100644 --- a/skills/read.md +++ b/skills/read.md @@ -7,7 +7,9 @@ title: Schema + Use — how to read a knowledge file # READ -Every consumer of BCQuality — an agent, an action skill, a human reviewer — reads this file first. It defines what a knowledge file is, what fields it contains, what they mean, and how to reconcile multiple files. +Read this contract before interpreting knowledge files. Task execution starts +at [Entry](entry.md); READ is loaded on demand when a dispatched skill needs +it. It defines knowledge fields, their meaning, and how to reconcile files. This contract is stable. Changes require a PR approved by both maintainers. @@ -131,7 +133,7 @@ Rules: - A sample file is identified by the article's slug followed by a `..` suffix. The supported kinds are `good` and `bad`. Additional kinds MAY be introduced by a layer; consumers MUST ignore unknown kinds without failing. - The extension matches the technology (`al`, `ps1`, `js`, `kql`, …). A single article MAY carry samples in multiple technologies if the article's frontmatter `technologies` lists them. -- Articles MAY have a `good` sample only, a `bad` sample only, both, or neither. The article text SHOULD reference each sample it ships, using a relative path like `` `.good.al` ``. +- Articles MAY have a `good` sample only, a `bad` sample only, both, or neither. The article text SHOULD reference each sample it ships with a relative Markdown link whose label retains the backticked filename, like `` [`.good.al`](.good.al) ``. - Samples are **demonstration-only**. They are not deployed, not compiled as part of a published app, and not derived from the Business Central base application source. Each sample is self-contained and exists purely to make the accompanying article concrete for humans and agents. - Layer precedence applies to sample files the same way it applies to articles: a `/custom/knowledge//.good.al` overrides a `/microsoft/knowledge//.good.al` for the same article in the same layer hierarchy. diff --git a/skills/write.md b/skills/write.md index 8096f1a..c2edab5 100644 --- a/skills/write.md +++ b/skills/write.md @@ -16,7 +16,7 @@ Before authoring anything, confirm a knowledge file is the right artifact. BCQua - **Skills** (`*/skills/**`) hold only finder/applier mechanics — how to discover, filter, worklist, and emit findings. See `skills/do.md`. - **Knowledge files** (`*/knowledge/**`) hold every Business-Central-specific fact a skill acts on. -A new BC fact is therefore a knowledge file, never a skill edit. In particular, if you arrived here because a review agent flagged something it should not have (a false positive) or missed something it should have caught, the remedy is a knowledge file — apply the admission test in the [README](../README.md#what-belongs-here): *would a capable LLM get this wrong without the file?* If you find yourself editing a skill to stop it flagging something, stop and write a knowledge file instead. +A new BC fact is therefore a knowledge file, never a skill edit. In particular, if you arrived here because a review agent flagged something it should not have (a false positive) or missed something it should have caught, the remedy is a knowledge file — apply the [admission test](../docs/contributing.md#what-belongs-here): *would a capable LLM get this wrong without the file?* If you find yourself editing a skill to stop it flagging something, stop and write a knowledge file instead. ### Negative knowledge is first-class @@ -56,6 +56,13 @@ Target under 100 lines. Ideal under 50. Long files almost always mean two concer Custom `##` sections are permitted when they serve the concern (for example, `## Applies to` for scope caveats or `## See also` for related files). Consumers are not required to understand them, so do not put load-bearing content there. +When adding or changing a platform claim, cite an authoritative public source +where available. A short `## References` section can link the relevant API, +property documentation, or public source definition. If no such source is +available, identify the evidence or policy basis explicitly; do not imply an +official guarantee. Keep the actual rule and its exceptions in normative +sections, not only in references. See [sources and examples](../docs/contributing.md#sources-and-examples). + ## No fenced code blocks Knowledge files do not contain code. Samples live as **sibling files** next to the article — `.good.al`, `.bad.al`, etc. — in the same knowledge-layer folder. See `skills/read.md` for the full convention. This keeps knowledge files retrieval-friendly and prevents code from drifting out of sync with BC platform changes buried inside prose. @@ -93,7 +100,7 @@ The `/custom/` layer is **empty by default** in the upstream `microsoft/BCQualit Before authoring or scaffolding any file under `/custom/knowledge/` or `/custom/skills/`, an author — human or agent — MUST confirm the working repository is **not** `microsoft/BCQuality`: - Check the `origin` remote: `git remote get-url origin`. If it points at `github.com/microsoft/BCQuality`, stop — you are in the upstream repo, not a fork. -- If you are in the upstream repo, do not write the file. Either fork the repository (or clone it into your organization's own repo) and add the custom content there, or — if the guidance is genuinely shareable — author it in `/community/knowledge/` instead. +- If you are in the upstream repo, do not write the custom file. Either fork the repository (or clone it into your organization's own repo) and add the custom content there, or — if the guidance is genuinely shareable — use the shared layer that owns the domain, following *Choosing a layer* above. Community is not a staging area for Microsoft-owned domains. A pull request that adds `/custom/` content to `microsoft/BCQuality` will be **automatically closed** by the `Guard custom layer` workflow. Validate the fork precondition first so authoring effort is not wasted on a PR that cannot be merged. @@ -109,7 +116,8 @@ Before opening a pull request: - Frontmatter `domain` exactly matches the containing domain folder. - File is in the correct layer and domain folder. - Name is kebab-case and descriptive. -- Every companion sample is referenced by filename from the article, and every referenced sample exists. +- Every companion sample has a clickable relative link retaining its backticked filename, and every referenced sample exists. +- Platform claims link supporting sources where available; policy or empirical guidance is identified as such. - Every review-leaf domain has at least one article with both `.good.al` and `.bad.al` companions; the evaluation harness derives positive and clean controls from that convention automatically. Agents scaffolding new files SHOULD run this checklist programmatically before emitting the file. From ac9e4fd9a28952bb58d02a23f1aadc1482240618 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Fri, 11 Sep 2026 09:09:53 +0200 Subject: [PATCH 68/86] Complete partner contribution and knowledge consumption guides (#176) * Improve partner onboarding and documentation navigation Lead with a complete plugin quick start and add task-oriented usage, troubleshooting, customization, and contribution guides. Preserve the broader plugin framing, correct conflicting contract guidance, support Agents folder reviews, and align repository validation. Convert existing sample references to clickable links without changing knowledge rules. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Complete partner contribution and knowledge consumption guides Explain direct reading, supplied skills, and custom-agent consumption. Add a first-contribution walkthrough and concrete integration bootstrap, and clarify SetLoadFields guidance with authoritative sources and explicit review heuristics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 5 +- docs/README.md | 4 +- docs/agent-consumption.md | 59 +++++++++++++++++++ docs/contributing.md | 42 +++++++++++++ docs/standalone-runner.md | 3 + docs/using-bcquality.md | 53 +++++++++++++++-- .../use-setloadfields-for-partial-records.md | 11 +++- 7 files changed, 168 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9eeee91..2118ce0 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,13 @@ the review can still discover knowledge by reading the folders. | I want to... | Start here | | --- | --- | +| Choose direct reading, a supplied skill, or my own agent | [Ways to use BCQuality](docs/using-bcquality.md#choose-how-to-use-bcquality) | | Review a file, changes, a branch, or a particular concern | [Using BCQuality](docs/using-bcquality.md) | | Resolve setup problems, incomplete reviews, or incorrect findings | [Troubleshooting and support](docs/troubleshooting.md) | | Browse the available guidance | [Knowledge by domain](docs/using-bcquality.md#knowledge-by-domain) | | Configure the plugin or use my organization's rules | [Customizing BCQuality](docs/customizing-bcquality.md) | -| Contribute knowledge or improve a rule | [Contributing](docs/contributing.md) | -| Connect a host, agent, or CI integration | [How agents consume BCQuality](docs/agent-consumption.md) | +| Contribute knowledge or improve a rule | [Your first contribution](docs/contributing.md#your-first-contribution) | +| Connect a host, agent, or CI integration | [Minimal integration example](docs/agent-consumption.md#try-a-minimal-integration) | [All documentation and technical references](docs/README.md). diff --git a/docs/README.md b/docs/README.md index 1d7248b..d9ca27b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,12 +8,13 @@ of BCQuality's internal protocol is needed. | Goal | Guide | | --- | --- | +| Choose direct reading, a supplied skill, or my own agent | [Ways to use BCQuality](using-bcquality.md#choose-how-to-use-bcquality) | | Review an app, file, changes, or branch | [Using BCQuality](using-bcquality.md) | | Understand a report and its limitations | [Reading your results](using-bcquality.md#reading-your-results) | | Find a particular rule or example | [Knowledge by domain](using-bcquality.md#knowledge-by-domain) | | Fix setup problems or report an incorrect finding | [Troubleshooting and support](troubleshooting.md) | | Select layers, add company rules, or maintain a fork | [Customizing BCQuality](customizing-bcquality.md) | -| Add or improve shared knowledge | [Contributing](contributing.md) | +| Add or improve shared knowledge | [Your first contribution](contributing.md#your-first-contribution) | ## Integration and technical reference @@ -22,6 +23,7 @@ prerequisites for using the plugin. | Reference | Purpose | | --- | --- | +| [Minimal integration example](agent-consumption.md#try-a-minimal-integration) | A bootstrap prompt connecting your agent to a BCQuality checkout and an app folder. | | [How agents consume BCQuality](agent-consumption.md) | Architecture, repository structure, routing, and delivery of findings. | | [Standalone runner](standalone-runner.md) | Optional model selection, scheduling, retries, and telemetry. | | [Global skills](../skills/README.md) | Host adapters versus internal protocol files. | diff --git a/docs/agent-consumption.md b/docs/agent-consumption.md index 1bf4284..06858c0 100644 --- a/docs/agent-consumption.md +++ b/docs/agent-consumption.md @@ -10,6 +10,65 @@ mental model. This is the operational reference for integration authors. Partners using the installed plugin do not need to implement this flow themselves. +## Try a minimal integration + +**"Invoke `skills/entry.md`" means ask your agent to read and follow that +instruction document.** It is not a shell command, HTTP endpoint, or executable +library. Your host must be able to read files, enumerate directories, and +execute the selected skills as instructed. Merely mentioning BCQuality does +not make its content available to the model. + +For a first integration, create or reuse a dedicated BCQuality checkout. +For example, in PowerShell: + +```powershell +git clone https://github.com/microsoft/BCQuality.git "C:\Knowledge\BCQuality" +``` + +Give the host access to **both** that content directory and your own app +directory. The plugin is not required for this route. Replace the paths and +BC version below with your actual values, then send this prompt to the agent: + +```text +BCQuality root: C:\Knowledge\BCQuality +Review input: folder-path = C:\Repos\MyBusinessCentralApp + +Read BCQuality's skills\entry.md and follow it with this task context: +task-context: + goal: Review the complete AL app without changing its source files. + inputs-available: [folder-path] + technologies: [al] + bc-version: 28 + enabled-layers: [microsoft, community, custom] + disabled-skills: [] + +Resolve BCQuality instructions, knowledge, and index preparation against the +BCQuality root, not the app directory. Pass the actual review-input path above +when a dispatched skill accepts folder-path. +Follow Entry's preparation and dispatch instructions. Execute every dispatched +action skill with its exact input subset, reading READ and DO on demand. +Return each complete findings report unchanged. If Entry returns no-match or +failed, return that dispatch record unchanged instead of inventing a review. +``` + +`inputs-available` lists input **types**; the `Review input` line binds the type +to the actual app directory. It is not an extra Entry schema field. Omit +`bc-version` when unknown rather than guessing it; add localization or +application-area context only when known. Keep the two roots distinct so index +preparation operates on BCQuality, not your app. + +Expect Entry to select the action skills and the agent to execute them. +A broad review normally returns the Microsoft coordinator's report with +domain `sub-results`, plus any separately dispatched reports. Each report +must retain its outcome, including incomplete or failed work; see +[reading results](using-bcquality.md#reading-your-results). A dispatch record +alone is not a completed review. + +This prompt delegates the existing protocol rather than implementing new +routing logic. For repeatable runs, [pin the checkout](customizing-bcquality.md#updates-and-versions). +Add scheduling, retries, and rendering only when needed, using the +[runner contract](standalone-runner.md). + ## The actors - **Orchestrator** — the tool that triggers work. Lives *outside* BCQuality. Knows *when* to run something, not *what* to run. diff --git a/docs/contributing.md b/docs/contributing.md index b4cbb0b..0e493c2 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -6,6 +6,34 @@ Partners are welcome to contribute shared knowledge, examples, skills, and documentation. To report an incorrect finding without preparing a change, use the [support guide](troubleshooting.md#reporting-a-problem). +## Your first contribution + +You can propose shared guidance without write access to the upstream +repository. Use this path for a correction or a new article: + +1. Search the [existing knowledge](using-bcquality.md#knowledge-by-domain) and + [open issues](https://github.com/microsoft/BCQuality/issues). Correct or + extend an existing article when it already owns the concern; add a new + article only for a distinct concern that meets the admission test below. +2. [Fork BCQuality](https://github.com/microsoft/BCQuality/fork) into your GitHub + account or organization, clone your fork, and create a working branch from + the current upstream `main`. Make edits in that branch, not the plugin cache. +3. Choose the [owning layer and domain](#choose-the-right-destination), then + edit the article or use the [shared-article starter](#shared-article-starter). + A contribution intended for everyone does not belong in `custom/`. +4. Add supporting sources and relevant good/bad samples. For a false positive, + explain the valid pattern and the mistaken finding the rule should prevent. +5. Run the [documented checks](#before-opening-a-pr), then commit and push + your branch to your fork. +6. On GitHub, open a pull request with **base repository + `microsoft/BCQuality`, base branch `main`**, and your fork's working branch + as the head. Explain why the change is needed and respond to review by + pushing further commits to the same branch. + +Merged content is not automatically loaded into an existing agent session. +Consumers must pick up the updated content through their installation or +checkout; see [updates and versions](customizing-bcquality.md#updates-and-versions). + ## What belongs here BCQuality is a remedial knowledge base. A knowledge file exists because a @@ -69,6 +97,20 @@ to catch in `Anti Pattern`; those are the normative sections. Explain legitimate exceptions so a reviewer does not turn a useful rule into a false positive. Code fences are not allowed in knowledge articles. +### Shared-article starter + +Use [caption-required-on-page-fields.md](../microsoft/knowledge/style/caption-required-on-page-fields.md) +as a complete shared-knowledge example. It demonstrates all six metadata +fields, a clear concern, normative guidance and exceptions, linked good/bad +samples, and authoritative sources. + +For a new concern, follow that structure but choose your own descriptive +filename, domain, applicability, keywords, and guidance. Replace its sources +and sample links with ones supporting your concern; do not duplicate the +caption rule. If you are correcting caption guidance itself, edit the +existing article instead. Use a company-only rule only in your fork's Custom +layer, following the separate [customization example](customizing-bcquality.md#add-an-organization-specific-rule). + ### Sources and examples When adding or changing a platform claim, link the authoritative source that diff --git a/docs/standalone-runner.md b/docs/standalone-runner.md index 31ab1dd..61ecb55 100644 --- a/docs/standalone-runner.md +++ b/docs/standalone-runner.md @@ -12,6 +12,9 @@ Use the built-in standalone plugin when the host's default execution is sufficient. Build a runner when you need explicit control over cost, latency, concurrency, or integration with another review surface. +Start with the [minimal integration example](agent-consumption.md#try-a-minimal-integration) +to connect your agent to the content before adding runner-specific behavior. + ## Keep BCQuality current For plugin installation, use the [quick start](../README.md#quick-start). diff --git a/docs/using-bcquality.md b/docs/using-bcquality.md index bb1d955..44e5975 100644 --- a/docs/using-bcquality.md +++ b/docs/using-bcquality.md @@ -2,10 +2,55 @@ [Documentation](README.md) | [Quick start](../README.md#quick-start) | [Troubleshooting](troubleshooting.md) -BCQuality supplies knowledge and reusable skills to your AI host. The plugin -currently exposes `al-code-review`; the examples below use that skill. The -host supplies authentication, model access, tools, permissions, and rendering. -Installing BCQuality does not install a Business Central extension or an agent. +BCQuality is knowledge you can read and reuse, plus skills that tell an agent +how to apply it. You do not need an AI tool to read the articles. When using +an agent, your host supplies authentication, model access, tools, permissions, +and rendering; BCQuality does not install a BC extension or an agent. + +## Choose how to use BCQuality + +| Path | What to do | +| --- | --- | +| Read the knowledge yourself | Browse [knowledge by domain](#knowledge-by-domain), or search the repository for an AL concept. Read the article and its samples. No installation required. | +| Use a supplied skill | Follow the [plugin quick start](../README.md#quick-start). The currently exposed skill, `al-code-review`, performs reviews and returns findings. | +| Use your own agent or workflow | Supply selected articles as context, as described below, or use the [integration bootstrap](agent-consumption.md#try-a-minimal-integration) to execute BCQuality action skills without the plugin. | + +### Read and reuse an article + +Start with a concern, such as `SetLoadFields`, and search within +`microsoft/BCQuality` on GitHub or open its domain folder. For example, +[partial-record guidance](../microsoft/knowledge/performance/use-setloadfields-for-partial-records.md) +explains the concern and links good/bad samples. + +Before applying an article, read its frontmatter, the small metadata block at +the top: + +| Field | How to read it | +| --- | --- | +| `bc-version` | `[24..]` means BC 24 and later; `[26..28]` means BC 26 through 28; `[all]` means every version. Use your target BC major version, not your extension's version. | +| `technologies` | `[al]` means the guidance applies to AL; multiple values identify the technologies the article covers. | +| `countries` | `[w1]` means worldwide; a code such as `[dk]` limits the guidance to that localization. | +| `application-area` | `[all]` means any application area; a named area narrows applicability. | +| `domain` and `keywords` | Help you find the topic; they are not instructions or additional requirements. | + +Read `Description` for context, then `Best Practice` and `Anti Pattern` for the +rule and its exceptions. Follow any sample and source links. Do not turn a +sample into a production implementation without considering your own context. +The [READ reference](../skills/read.md) defines the precise matching rules. + +To use an article with your own agent, give it access to the full article and +relevant samples, not just a title or index row. For example, replace the +bracketed values in this prompt: + +> Read [article URL or local path] and its linked samples. Apply the relevant +> guidance while implementing [task] for BC [major version]. Explain which +> guidance you used, cite the article, and identify any missing context. + +A URL only works if the host can retrieve it; otherwise provide the files +directly. This is ordinary reuse of knowledge for explanation or code writing, +**not a packaged code-generation skill or a complete BCQuality review**. +For the structured review process, invoke a supplied skill or follow the +integration protocol. The remaining sections describe the review workflow. ## Hosts and prerequisites diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md index 06b2d14..de92bb1 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -11,11 +11,13 @@ application-area: [all] ## Description -`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. Its position relative to `SetRange`/`SetFilter` does not change the projection: filtered fields are added to the load set at read time either way. Projection-changing operations are separate: `AddLoadFields(...)` expands the selection, a later `SetLoadFields(...)` or `SetBaseLoadFields()` overwrites it, and `Reset()` or a fieldless `SetLoadFields()` restores all readable normal fields. +`SetLoadFields(...)` declares the subset of normal fields the next read should materialize. Microsoft's partial-record guidance explains how loading fewer fields reduces work, particularly for read loops and tables with extensions. Primary-key fields, `SystemId`, system audit fields, and fields being filtered on are loaded automatically; those do not need to appear in the selection. Only `FieldClass = Normal` fields can be selected, not FlowFields or FlowFilters. + +Its position relative to `SetRange`/`SetFilter` does not change the projection: filtered fields are included at read time either way. Projection-changing operations are separate: `AddLoadFields(...)` expands the selection, a later `SetLoadFields(...)` or `SetBaseLoadFields()` overwrites it, and `Reset()` or a fieldless `SetLoadFields()` restores all readable normal fields. The Microsoft Learn references below document the selection and reset behavior. ## Best Practice -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. Place the call immediately before the read, after any `SetRange`/`SetFilter`, so a reader can see at a glance which read the selection governs and any projection-changing operation is easy to spot. 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`). +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. For example, `SetLoadFields(...); if Record.Get(...) then ...` selects fields before the read. Place the call immediately before the read, after any `SetRange`/`SetFilter`, so a reader can see at a glance which read the selection governs and any projection-changing operation is easy to spot. 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](singleton-setup-tables-need-no-access-optimization.md), [temporary tables](temporary-tables-have-no-database-cost.md)). The numeric cutoffs are BCQuality review heuristics, not Microsoft platform thresholds. For report dataitems, use `AddLoadFields` in `OnPreDataItem` instead (see [report partial loads](addloadfields-in-report-onpredataitem.md)). See sample: [`use-setloadfields-for-partial-records.good.al`](use-setloadfields-for-partial-records.good.al). @@ -26,3 +28,8 @@ Loading a wide table and reading one field per row in a loop. The bytes transfer Statement order is not part of this anti pattern. `SetLoadFields` placed ahead of `SetRange`/`SetFilter` materializes exactly the same columns as the reverse order, so a reviewer reports it as a readability observation at most — never as a performance defect. See sample: [`use-setloadfields-for-partial-records.bad.al`](use-setloadfields-for-partial-records.bad.al). + +## References + +- [Record.SetLoadFields remarks](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-setloadfields-method#remarks): automatically loaded fields, normal-field restrictions, and resetting the selection. +- [Using partial records](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-partial-records): performance rationale, load-selection APIs, reset behavior, and report guidance. From c12b2f0a88c7316b82d2c9e01dfb8518a9eb6eea Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Fri, 11 Sep 2026 09:26:14 +0200 Subject: [PATCH 69/86] Separate analyzer rules from BCQuality knowledge (#178) Retire deterministic compiler and analyzer duplicates, remove their review routing, and clarify the admission test for contextual analyzer knowledge. Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 + docs/contributing.md | 13 +++++- docs/using-bcquality.md | 6 +++ evaluation/README.md | 2 +- evaluation/review-fixtures.json | 3 -- .../object-affixes-prevent-collisions.bad.al | 43 ------------------- .../object-affixes-prevent-collisions.good.al | 40 ----------------- .../object-affixes-prevent-collisions.md | 30 ------------- ...ct-affix-not-extension-member-affix.bad.al | 22 ---------- ...t-affix-not-extension-member-affix.good.al | 22 ---------- ...object-affix-not-extension-member-affix.md | 28 ------------ ...s-part-of-published-object-identity.bad.al | 9 ---- ...-part-of-published-object-identity.good.al | 8 ---- ...ce-is-part-of-published-object-identity.md | 26 ----------- ...ationarea-required-on-page-controls.bad.al | 25 ----------- ...tionarea-required-on-page-controls.good.al | 40 ----------------- ...plicationarea-required-on-page-controls.md | 28 ------------ .../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 ----------- ...-subscriber-param-names-match-publisher.md | 26 ----------- .../function-call-parentheses-required.bad.al | 11 ----- ...function-call-parentheses-required.good.al | 11 ----- .../function-call-parentheses-required.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 ------------ ...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-space-before-method-parenthesis.bad.al | 11 ----- ...no-space-before-method-parenthesis.good.al | 11 ----- .../no-space-before-method-parenthesis.md | 26 ----------- ...on-required-and-matches-membercount.bad.al | 17 -------- ...n-required-and-matches-membercount.good.al | 18 -------- ...aption-required-and-matches-membercount.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 ----------- .../style/this-keyword-in-codeunits.bad.al | 14 ------ .../style/this-keyword-in-codeunits.good.al | 14 ------ .../style/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 ----------- .../skills/review/al-appsource-review.md | 29 ++++++------- .../review/al-breaking-changes-review.md | 5 +-- microsoft/skills/review/al-style-review.md | 22 +++++----- 60 files changed, 45 insertions(+), 1095 deletions(-) delete mode 100644 microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al delete mode 100644 microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al delete mode 100644 microsoft/knowledge/appsource/object-affixes-prevent-collisions.md delete mode 100644 microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al delete mode 100644 microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al delete mode 100644 microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md delete mode 100644 microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.bad.al delete mode 100644 microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.good.al delete mode 100644 microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md delete mode 100644 microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al delete mode 100644 microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al delete mode 100644 microsoft/knowledge/style/applicationarea-required-on-page-controls.md delete mode 100644 microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al delete mode 100644 microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al delete mode 100644 microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md delete mode 100644 microsoft/knowledge/style/block-keywords-start-new-line.bad.al delete mode 100644 microsoft/knowledge/style/block-keywords-start-new-line.good.al delete mode 100644 microsoft/knowledge/style/block-keywords-start-new-line.md delete mode 100644 microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md delete mode 100644 microsoft/knowledge/style/function-call-parentheses-required.bad.al delete mode 100644 microsoft/knowledge/style/function-call-parentheses-required.good.al delete mode 100644 microsoft/knowledge/style/function-call-parentheses-required.md delete mode 100644 microsoft/knowledge/style/label-suffix-approved-list.bad.al delete mode 100644 microsoft/knowledge/style/label-suffix-approved-list.good.al delete mode 100644 microsoft/knowledge/style/label-suffix-approved-list.md delete mode 100644 microsoft/knowledge/style/lowercase-reserved-keywords.bad.al delete mode 100644 microsoft/knowledge/style/lowercase-reserved-keywords.good.al delete mode 100644 microsoft/knowledge/style/lowercase-reserved-keywords.md delete mode 100644 microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al delete mode 100644 microsoft/knowledge/style/no-begin-end-around-single-statement.good.al delete mode 100644 microsoft/knowledge/style/no-begin-end-around-single-statement.md delete mode 100644 microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al delete mode 100644 microsoft/knowledge/style/no-space-before-method-parenthesis.good.al delete mode 100644 microsoft/knowledge/style/no-space-before-method-parenthesis.md delete mode 100644 microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al delete mode 100644 microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al delete mode 100644 microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md delete mode 100644 microsoft/knowledge/style/single-space-after-not-operator.bad.al delete mode 100644 microsoft/knowledge/style/single-space-after-not-operator.good.al delete mode 100644 microsoft/knowledge/style/single-space-after-not-operator.md delete mode 100644 microsoft/knowledge/style/single-space-around-binary-operators.bad.al delete mode 100644 microsoft/knowledge/style/single-space-around-binary-operators.good.al delete mode 100644 microsoft/knowledge/style/single-space-around-binary-operators.md delete mode 100644 microsoft/knowledge/style/this-keyword-in-codeunits.bad.al delete mode 100644 microsoft/knowledge/style/this-keyword-in-codeunits.good.al delete mode 100644 microsoft/knowledge/style/this-keyword-in-codeunits.md delete mode 100644 microsoft/knowledge/style/variable-declaration-order-by-type.bad.al delete mode 100644 microsoft/knowledge/style/variable-declaration-order-by-type.good.al delete mode 100644 microsoft/knowledge/style/variable-declaration-order-by-type.md delete mode 100644 microsoft/knowledge/style/variable-name-must-not-shadow.bad.al delete mode 100644 microsoft/knowledge/style/variable-name-must-not-shadow.good.al delete mode 100644 microsoft/knowledge/style/variable-name-must-not-shadow.md diff --git a/README.md b/README.md index 2118ce0..e9e18d5 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ the agent's judgment; it is not an exhaustive BC manual or a substitute for compilation, analyzers, tests, or human review. See [coverage and limits](docs/using-bcquality.md#coverage-and-limits) for the available domains and the difference between a folder review and a comparison. +Mechanical issues already enforced by the AL compiler or standard analyzers are +intentionally left to those deterministic tools rather than duplicated here. Functional areas such as Finance, Supply Chain Management, Manufacturing, Jobs, Warehousing, and Service, and technologies such as PowerShell, pipelines, and diff --git a/docs/contributing.md b/docs/contributing.md index 0e493c2..27da177 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -41,7 +41,9 @@ capable LLM **would get something wrong, or miss something, without it**, not simply because the topic is important. Apply this admission test: > If this file did not exist, would a modern LLM reviewing or generating BC -> code make a mistake this file would have prevented? +> code make a BC-specific mistake that the configured compiler, analyzers, and +> tests would not reliably catch, or would it misinterpret or incorrectly +> remediate one of their diagnostics? Good candidates encode a BC-specific mechanic that models get wrong, a version-dependent behavior, or a misleading interpretation of an analyzer @@ -56,6 +58,15 @@ transactions short" does not earn a separate knowledge file merely by being sound advice. Negative clarifications that prevent false positives are as valuable as rules that catch defects. +Do not add knowledge whose anti-pattern is fully and deterministically detected +by the AL compiler or a standard analyzer. This applies to authoring as well as +review: an authoring agent should compile with the consuming app's actual +ruleset and correct the resulting diagnostics instead of carrying prose copies +of analyzer rules in context. Analyzer-related knowledge belongs here only when +it adds a BC-specific exception, version boundary, cross-object implication, or +remediation constraint that the diagnostic itself cannot establish. Merely +explaining why a deterministic rule exists is not sufficient. + **Skills hold discovery and execution mechanics; knowledge files hold BC facts.** Correct or extend a knowledge article when a BC fact is missing or wrong. Do not hide that fact in a skill's instructions. A genuine routing, diff --git a/docs/using-bcquality.md b/docs/using-bcquality.md index 44e5975..dcaddde 100644 --- a/docs/using-bcquality.md +++ b/docs/using-bcquality.md @@ -196,6 +196,12 @@ removal or another comparison-only regression requires an actual baseline. The corpus is technical AL guidance, not exhaustive functional validation or AppSource certification. +BCQuality intentionally does not duplicate mechanical diagnostics already +enforced by the AL compiler or standard analyzers. Run the consuming app's +normal compiler and analyzer pipeline alongside review and authoring. Knowledge +may still discuss a diagnostic when BC-specific context is needed to avoid a +false positive or choose a correct remediation. + ### Knowledge by domain Each article describes one concern. Where samples exist, use its linked diff --git a/evaluation/README.md b/evaluation/README.md index 7063baf..7be55b4 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -36,7 +36,7 @@ This credential-free check proves every selected leaf maps to a same-named knowl { "id": "case-a1b2c3d4", "findings": [ - { "id": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md" } + { "id": "microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md" } ] } ] diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index f5c7046..e11508a 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -7,9 +7,6 @@ "agents": { "article": "wire-all-three-agent-interfaces" }, - "appsource": { - "context": "AppSourceCop mandatoryAffixes is configured to ABC." - }, "breaking-changes": { "article": "do-not-expose-sensitive-data-through-public-api" }, diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al deleted file mode 100644 index dc1c6f3..0000000 --- a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al +++ /dev/null @@ -1,43 +0,0 @@ -// Anti-pattern: an own object with no affix. Another app that also defines a -// "Loyalty Tier" table cannot be installed alongside this one. -table 50379 "Loyalty Tier" -{ - Caption = 'Loyalty Tier'; - DataClassification = CustomerContent; - - fields - { - field(1; "Code"; Code[20]) - { - Caption = 'Code'; - } - field(10; Description; Text[100]) - { - Caption = 'Description'; - } - } - - keys - { - key(PK; "Code") - { - Clustered = true; - } - } -} - -// Anti-pattern (the common half-measure): the extension object carries the -// affix, but the field it adds to the standard Customer table does not. That -// unaffixed field still collides with any other app that adds "Loyalty Points" -// to Customer, and AS0011 flags it. -tableextension 50378 "ABC Customer Ext" extends Customer -{ - fields - { - field(50378; "Loyalty Points"; Integer) - { - Caption = 'Loyalty Points'; - DataClassification = CustomerContent; - } - } -} diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al deleted file mode 100644 index 28bfa4d..0000000 --- a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al +++ /dev/null @@ -1,40 +0,0 @@ -// Own object: the affix "ABC" is carried at object-name level. -table 50377 "ABC Loyalty Tier" -{ - Caption = 'Loyalty Tier'; - DataClassification = CustomerContent; - - fields - { - field(1; "Code"; Code[20]) - { - Caption = 'Code'; - } - field(10; Description; Text[100]) - { - Caption = 'Description'; - } - } - - keys - { - key(PK; "Code") - { - Clustered = true; - } - } -} - -// Extension of a standard object: the added field is individually affixed, -// because the object name (Customer) belongs to the base application. -tableextension 50376 "ABC Customer Ext" extends Customer -{ - fields - { - field(50376; "Loyalty Points ABC"; Integer) - { - Caption = 'Loyalty Points'; - DataClassification = CustomerContent; - } - } -} diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md deleted file mode 100644 index a53e584..0000000 --- a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -bc-version: [all] -domain: appsource -keywords: [object-affix, prefix, suffix, as0011, appsourcecop, collision, tableextension, first-party, isv] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Apply a reserved affix to objects and to members added to base objects - -## Description - -An AppSource extension must prevent name collisions through its registered affix or, on BC23 and later for objects it owns, a namespace with at least two levels. The affix still applies to every field, key, control, or action added to a base-application object; see `two-level-namespace-replaces-object-affix-not-extension-member-affix.md`. Without either mechanism, two apps that both define a `Loyalty Tier` table cannot coexist, and two apps that add an unaffixed `Loyalty Points` field to `Customer` still collide regardless of their namespaces. - -AppSourceCop enforces this. The primary rule is AS0011 ("An affix is required"); the affixes are configured through `mandatoryAffixes` (and `mandatoryPrefix`) in `AppSourceCop.json`. Two placements matter and are easy to get half-right: an object you define carries the affix at **object-name** level, while a member you add to a **standard** object carries the affix on that **member's** name. Adding an affixed object is not enough — an unaffixed field bolted onto `Customer` still collides and still fails validation. - -This rule scopes to Marketplace ISV extensions, which is what AppSourceCop validates. A first-party Microsoft in-box module (publisher `Microsoft`, an object range reserved for first-party use, and no `AppSourceCop.json`/`mandatoryAffixes` in the app) is not built or shipped as an Marketplace extension and is not subject to AS0011, so an unaffixed action or field it adds to a base-application page is not a collision risk to flag. Renaming an existing shipped first-party member to add an affix is itself a breaking change to that module's own history and is not required by this rule. - -## Best Practice - -Own objects use the registered affix (for example `ABC Loyalty Tier`) or, when targeting BC23 or later, a qualifying namespace. Every field or action added to a standard object remains individually affixed (for example `Loyalty Points ABC` on a `Customer` tableextension). - -See sample: [`object-affixes-prevent-collisions.good.al`](object-affixes-prevent-collisions.good.al). - -## Anti Pattern - -An owned object with neither a qualifying namespace nor an affix, an unaffixed extension member, or the common half-measure where the extension object carries the affix but a field it adds to a standard table does not. AS0011 flags the missing collision protection and the field can still collide with another app. - -See sample: [`object-affixes-prevent-collisions.bad.al`](object-affixes-prevent-collisions.bad.al). diff --git a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al deleted file mode 100644 index de2d3ed..0000000 --- a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al +++ /dev/null @@ -1,22 +0,0 @@ -namespace Contoso; - -table 50462 "Rental Agreement" -{ - DataClassification = CustomerContent; - - fields - { - field(1; "No."; Code[20]) { } - } -} - -tableextension 50463 "Rental Customer Ext" extends Customer -{ - fields - { - field(50463; "Loyalty Points"; Integer) - { - DataClassification = CustomerContent; - } - } -} diff --git a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al deleted file mode 100644 index 93fa9c6..0000000 --- a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al +++ /dev/null @@ -1,22 +0,0 @@ -namespace Contoso.Rentals; - -table 50460 "Rental Agreement" -{ - DataClassification = CustomerContent; - - fields - { - field(1; "No."; Code[20]) { } - } -} - -tableextension 50461 "Rental Customer Ext" extends Customer -{ - fields - { - field(50461; "Loyalty Points RNT"; Integer) - { - DataClassification = CustomerContent; - } - } -} diff --git a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md deleted file mode 100644 index fef6d6c..0000000 --- a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [23..] -domain: appsource -keywords: [namespace, two-level, affix, prefix, suffix, as0011, tableextension, pageextension, false-positive] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# A two-level namespace replaces an object affix, not an extension-member affix - -## Description - -Current AppSource naming guidance accepts a namespace with at least two levels, such as `Contoso.Rentals`, instead of a registered prefix or suffix on the names of objects the app owns. The namespace does not qualify members added to another publisher's object: fields, keys, controls, and actions introduced through table or page extensions still share the target object's flat member namespace and still need the registered affix. - -The requirement comes from AppSourceCop rule AS0011, which only runs when the app enables AppSourceCop and configures a mandatory affix — normally an `AppSourceCop.json` next to the app manifest. An app that ships no such configuration is not subject to AS0011, and its extension members are not a compliance gap. This is the usual situation for first-party, in-box apps that ship as part of the product rather than through AppSource: their uniqueness comes from allocated object ID ranges and a controlled source tree, not from a registered affix. Confirm the extending app actually configures a mandatory affix before reporting an unaffixed extension member. - -## Best Practice - -Choose one collision strategy for owned objects: a registered affix or a globally meaningful namespace with at least two levels. Regardless of that choice, apply the registered affix to every member added to a base or third-party object. Keep the affix configured for AppSourceCop so member validation remains deterministic. Do not raise a missing member affix against an app that does not enable AppSourceCop with a mandatory affix; there AS0011 never fires, and the app's namespace is not the reason — the absent configuration is. - -See sample: [`two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al`](two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al). - -## Anti Pattern - -Using `namespace Contoso;` as though one level satisfied the AppSource alternative, or declaring `namespace Contoso.Rentals;` and then adding an unaffixed `Loyalty Points` field to `Customer` in an app that does configure a mandatory affix. The namespace distinguishes the extension's own objects; it cannot disambiguate members on Customer. The mirror-image mistake is reporting an unaffixed extension member in an app that enables no mandatory affix at all — AS0011 does not apply there, and the finding is a false positive. - -See sample: [`two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al`](two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al). diff --git a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.bad.al b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.bad.al deleted file mode 100644 index e2a5152..0000000 --- a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.bad.al +++ /dev/null @@ -1,9 +0,0 @@ -// This published object previously used namespace Contoso.Rentals. -namespace Contoso.RentalManagement; - -codeunit 50467 "Rental Agreement Mgt." -{ - procedure CreateAgreement() - begin - end; -} diff --git a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.good.al b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.good.al deleted file mode 100644 index ea151a8..0000000 --- a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.good.al +++ /dev/null @@ -1,8 +0,0 @@ -namespace Contoso.Rentals; - -codeunit 50466 "Rental Agreement Mgt." -{ - procedure CreateAgreement() - begin - end; -} diff --git a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md deleted file mode 100644 index 6c345ee..0000000 --- a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [23..] -domain: breaking-changes -keywords: [namespace, published-object, dependency, breaking-change, as0007, compile-time-identity] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Treat a published namespace as part of object identity - -## Description - -AL resolves an object by namespace and name. Once an app ships and dependent extensions compile against that identity, changing the namespace breaks their references even when the object name and ID stay unchanged. AppSourceCop AS0007 rejects changing the namespace of published objects; namespaces are therefore not a cosmetic folder-like label that can be reorganized after release. - -## Best Practice - -Choose a globally meaningful namespace before first publication and keep it stable. Add new functional areas beneath that structure without moving existing published objects. If an identity must move, use the platform's supported move/obsoletion lifecycle rather than a source-only namespace rename. - -See sample: [`namespace-is-part-of-published-object-identity.good.al`](namespace-is-part-of-published-object-identity.good.al). - -## Anti Pattern - -Changing `namespace Contoso.Rentals;` to `namespace Contoso.RentalManagement;` as a cleanup while leaving the object name and ID untouched. Every dependent `using` directive and qualified reference targets the old identity and stops compiling. - -See sample: [`namespace-is-part-of-published-object-identity.bad.al`](namespace-is-part-of-published-object-identity.bad.al). diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al b/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al deleted file mode 100644 index 8da7777..0000000 --- a/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al +++ /dev/null @@ -1,25 +0,0 @@ -page 50375 "Sample App Area Bad" -{ - PageType = Card; - SourceTable = Customer; - layout - { - area(Content) - { - group(General) - { - // Anti-pattern: no ApplicationArea. AS0062 flags this control, - // and it is silently hidden in the Web client for profiles whose - // enabled areas do not already cover it. - field("No."; Rec."No.") - { - ToolTip = 'Specifies the number that identifies the customer.'; - } - field(Name; Rec.Name) - { - ToolTip = 'Specifies the customer''s name.'; - } - } - } - } -} diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al b/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al deleted file mode 100644 index d344337..0000000 --- a/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al +++ /dev/null @@ -1,40 +0,0 @@ -page 50374 "Sample App Area Good" -{ - PageType = Card; - SourceTable = Customer; - layout - { - area(Content) - { - group(General) - { - field("No."; Rec."No.") - { - ApplicationArea = All; - ToolTip = 'Specifies the number that identifies the customer.'; - } - field(Name; Rec.Name) - { - ApplicationArea = All; - ToolTip = 'Specifies the customer''s name.'; - } - } - } - } - actions - { - area(Processing) - { - action(Refresh) - { - ApplicationArea = All; - ToolTip = 'Reloads the current record.'; - - trigger OnAction() - begin - CurrPage.Update(false); - end; - } - } - } -} diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.md b/microsoft/knowledge/style/applicationarea-required-on-page-controls.md deleted file mode 100644 index 3766dda..0000000 --- a/microsoft/knowledge/style/applicationarea-required-on-page-controls.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [application-area, page-control, as0062, appsourcecop, hidden-control, web-client] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Every page control needs an `ApplicationArea` (AppSourceCop AS0062) - -## Description - -A field control on a page or pageextension that has no `ApplicationArea` property is silently hidden in the Web client for every profile whose enabled application areas do not cover it. There is no error and no warning at runtime — the field simply does not appear, which reads as data loss to the user. AppSourceCop AS0062 flags any page control or action that is missing the `ApplicationArea` property, and AppSource technical validation rejects the app until it is set. - -Set the property to an area the app actually enables. `All` makes the control visible under every profile and is the common default; if the app declares narrower areas in `app.json`, use one of those. The property applies to field controls and to actions. This is a sibling concern to `caption-required-on-page-fields.md` and `tooltip-required-on-page-fields.md`; note that the ToolTip requirement is the separate CodeCop rule AA0218, not AS0062. - -## Best Practice - -Every field control and action carries `ApplicationArea = All;` (or a declared area of the app). The value is set once per control and keeps the control visible in the Web client. - -See sample: [`applicationarea-required-on-page-controls.good.al`](applicationarea-required-on-page-controls.good.al). - -## Anti Pattern - -A field control with no `ApplicationArea`. AS0062 flags it, and the control is invisible in the Web client for any profile that does not already enable a matching area. - -See sample: [`applicationarea-required-on-page-controls.bad.al`](applicationarea-required-on-page-controls.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 deleted file mode 100644 index fd3ac45..0000000 --- a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 6043c5b..0000000 --- a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index de30708..0000000 --- a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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`](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`](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 deleted file mode 100644 index ef7f937..0000000 --- a/microsoft/knowledge/style/block-keywords-start-new-line.bad.al +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index eb6c3d4..0000000 --- a/microsoft/knowledge/style/block-keywords-start-new-line.good.al +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 8161a08..0000000 --- a/microsoft/knowledge/style/block-keywords-start-new-line.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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`](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`](block-keywords-start-new-line.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 deleted file mode 100644 index e408ebb..0000000 --- a/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [event-subscriber, parameter-name, publisher, signature, eventsubscriber, false-positive] -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. For every parameter the subscriber declares, the name is not a style choice — it must match the name 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. - -A subscriber may, however, declare fewer parameters than the publisher. AL binds each subscriber parameter to the publisher parameter of the same name, so the subscriber can omit any parameters its handler does not use, from any position, and can even declare the ones it keeps in a different order than the publisher. This compiles and binds correctly, so a shorter or differently ordered subscriber signature is not a signature mismatch. In shipping BCApps code, `Test Runner - Mgt::OnBeforeTestMethodRun` publishes `CurrentTestMethodLine, CodeunitID, CodeunitName, FunctionName, FunctionTestPermissions, Skip`, and subscribers such as `ALTestRunnerResetEnvironment` bind to it while omitting `Skip` and declaring `CurrentTestMethodLine` last. - -## Best Practice - -Copy each parameter's name and type from the publisher verbatim for every parameter the subscriber keeps, and omit the ones the handler does not use. 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. Do not flag a subscriber for declaring fewer parameters than the publisher, for omitting one from the middle of the list, or for declaring them in a different order, as long as every parameter it does declare matches a publisher parameter by name and type: that is valid AL, not a mismatch. - -## Anti Pattern - -Renaming a publisher parameter to look prettier in the subscriber. The build breaks immediately, because the name is what the runtime binds on. 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. - -Detection: a subscriber parameter whose name or type does not correspond to any parameter on the publisher — not a subscriber that merely declares fewer parameters, drops one from the middle, or lists them in a different order. diff --git a/microsoft/knowledge/style/function-call-parentheses-required.bad.al b/microsoft/knowledge/style/function-call-parentheses-required.bad.al deleted file mode 100644 index 2677716..0000000 --- a/microsoft/knowledge/style/function-call-parentheses-required.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 53f6f85..0000000 --- a/microsoft/knowledge/style/function-call-parentheses-required.good.al +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index f24985a..0000000 --- a/microsoft/knowledge/style/function-call-parentheses-required.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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`](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`](function-call-parentheses-required.bad.al). diff --git a/microsoft/knowledge/style/label-suffix-approved-list.bad.al b/microsoft/knowledge/style/label-suffix-approved-list.bad.al deleted file mode 100644 index 6b227de..0000000 --- a/microsoft/knowledge/style/label-suffix-approved-list.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index f3ec561..0000000 --- a/microsoft/knowledge/style/label-suffix-approved-list.good.al +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 2bda119..0000000 --- a/microsoft/knowledge/style/label-suffix-approved-list.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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`](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`](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 deleted file mode 100644 index 83d5994..0000000 --- a/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 25fb20e..0000000 --- a/microsoft/knowledge/style/lowercase-reserved-keywords.good.al +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7215491..0000000 --- a/microsoft/knowledge/style/lowercase-reserved-keywords.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -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`](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`](lowercase-reserved-keywords.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 deleted file mode 100644 index 1803e1c..0000000 --- a/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 684a86c..0000000 --- a/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 3c1cf9f..0000000 --- a/microsoft/knowledge/style/no-begin-end-around-single-statement.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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`](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`](no-begin-end-around-single-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 deleted file mode 100644 index b2f8295..0000000 --- a/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index eb16dc3..0000000 --- a/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 9c5ffea..0000000 --- a/microsoft/knowledge/style/no-space-before-method-parenthesis.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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`](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`](no-space-before-method-parenthesis.bad.al). diff --git a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al deleted file mode 100644 index 9d5269c..0000000 --- a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al +++ /dev/null @@ -1,17 +0,0 @@ -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/optioncaption-required-and-matches-membercount.good.al b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al deleted file mode 100644 index d0e9866..0000000 --- a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al +++ /dev/null @@ -1,18 +0,0 @@ -table 50254 "Sample OptionCaption Good" -{ - fields - { - field(1; Status; Option) - { - Caption = 'Status'; - OptionMembers = Open,Released,Pending; - OptionCaption = 'Open,Released,Pending'; - } - 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 deleted file mode 100644 index e90fb16..0000000 --- a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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`](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`](optioncaption-required-and-matches-membercount.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 deleted file mode 100644 index c7143e7..0000000 --- a/microsoft/knowledge/style/single-space-after-not-operator.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 92d8f33..0000000 --- a/microsoft/knowledge/style/single-space-after-not-operator.good.al +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 41e65fe..0000000 --- a/microsoft/knowledge/style/single-space-after-not-operator.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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`](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`](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 deleted file mode 100644 index 2515d33..0000000 --- a/microsoft/knowledge/style/single-space-around-binary-operators.bad.al +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 55793d3..0000000 --- a/microsoft/knowledge/style/single-space-around-binary-operators.good.al +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 1ad0184..0000000 --- a/microsoft/knowledge/style/single-space-around-binary-operators.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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`](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`](single-space-around-binary-operators.bad.al). diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al b/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al deleted file mode 100644 index 7fd03a1..0000000 --- a/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 392c042..0000000 --- a/microsoft/knowledge/style/this-keyword-in-codeunits.good.al +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index cb0a8a3..0000000 --- a/microsoft/knowledge/style/this-keyword-in-codeunits.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [25..] -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`](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`](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 deleted file mode 100644 index 3131fa6..0000000 --- a/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 590ed25..0000000 --- a/microsoft/knowledge/style/variable-declaration-order-by-type.good.al +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index a133e87..0000000 --- a/microsoft/knowledge/style/variable-declaration-order-by-type.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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`](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`](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 deleted file mode 100644 index 65c8223..0000000 --- a/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index f5391ef..0000000 --- a/microsoft/knowledge/style/variable-name-must-not-shadow.good.al +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 200cae2..0000000 --- a/microsoft/knowledge/style/variable-name-must-not-shadow.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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`](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`](variable-name-must-not-shadow.bad.al). diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md index 3ba30e4..ebbed36 100644 --- a/microsoft/skills/review/al-appsource-review.md +++ b/microsoft/skills/review/al-appsource-review.md @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source and app metadata changes against the `appsource` 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 a `pr-diff`, `file-path`, or `folder-path`. AppSource findings are narrow by design — they apply when the review scope contains AppSourceCop configuration, AL object or extension-member names, or AppSource-facing `app.json` metadata. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. AppSource findings are narrow by design — they apply to AppSource-facing metadata and complete permission coverage that requires repository context. Mechanical AppSourceCop diagnostics are intentionally outside this skill. The skill returns `not-applicable` when none of those surfaces apply. ## Source @@ -37,19 +37,14 @@ 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 files and AL object types — especially `app.json`, `AppSourceCop.json`, namespace declarations, permission-set objects, new objects, and table/page/report extensions that add fields, keys, controls, or actions to base objects. -- The changed object and member names, weighted toward prefix/suffix consistency with `mandatoryAffixes` or `mandatoryPrefix`, plus AppSource-facing help metadata. -- Tokens extracted from the diff that relate to AppSource (`AppSourceCop`, `mandatoryAffixes`, `mandatoryPrefix`, `AS0011`, `prefix`, `suffix`, `namespace`, `using`, `permissionset`, `Assignable`, `Permissions`, `SUPER`, `tableextension`, `pageextension`, `reportextension`, `field`, `key`, `control`, `action`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`). +- The changed files and AL object types — especially `app.json`, permission-set objects, setup and usage entry points, and AppSource-facing help metadata. +- Tokens extracted from the diff that relate to AppSource (`permissionset`, `Assignable`, `Permissions`, `SUPER`, `tabledata`, `execute`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no AppSource-related source or metadata changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. The following targeted checks cover every current `appsource` article across the Microsoft and community layers. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action. -- Select exactly one naming-collision owner. When no namespace declaration is present, a new/renamed object lacks the reserved prefix/suffix, or an extension object adds an unaffixed member to a base object — `object-affixes-prevent-collisions`. -- For BC23 or later, use `two-level-namespace-replaces-object-affix-not-extension-member-affix` instead when the changed source actually declares or changes a namespace and relies on it as the owned-object affix alternative, but has fewer than two levels or incorrectly applies that exception to members on another publisher's object. Never worklist this article for an unaffixed source file with no namespace declaration. - The app has no assignable permission set covering its setup and usage paths, omits visible object/tabledata grants, or requires `SUPER` for normal operation — `permission-sets-cover-setup-and-usage-without-super`. Require repository-level app context; one isolated permission-set object cannot prove complete coverage. - -Before emitting an affix finding, compare every owned object name and every member added to another publisher's object against the configured `mandatoryAffixes`/`mandatoryPrefix`. A matching prefix or suffix is compliant. Do not flag an `ABC`-prefixed object or an `ABC`-suffixed extension member when `ABC` is the configured affix. - For BC v27 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent — `keep-copilot-help-url-to-two-path-levels`. 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`. @@ -66,13 +61,13 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` Set `confidence` to: -- `high` when the detection is based on an unambiguous pattern match (affix configuration/name or URL path depth). +- `high` when the detection is based on an unambiguous pattern match such as URL path depth. - `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. - `low` when the finding is an advisory derived only from applicability. After evaluating each worklist entry, also consider whether the diff exhibits an AppSource defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material AppSource defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly AppSource; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. -For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add the configured affix to one object or extension member, or replace a deep help URL with a known two-level canonical URL). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example, replacing a deep help URL with a known two-level canonical URL). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. @@ -80,7 +75,7 @@ Outcome selection: - `completed` — the skill evaluated every worklist item. - `no-knowledge` — no applicable AppSource knowledge survived filtering. -- `not-applicable` — the diff touches no AppSource source, analyzer configuration, or app-metadata surface. +- `not-applicable` — the diff touches no AppSource permission or app-metadata surface. - `partial` — a budget was hit before the worklist was exhausted. - `failed` — an unrecoverable error occurred. @@ -98,19 +93,19 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s }, "findings": [ { - "id": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md", + "id": "microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md", "severity": "major", - "message": "The tableextension adds an unaffixed Loyalty Points field to Customer, so it violates the configured AppSource affix and can collide with another extension.", + "message": "The app help URL is deeper than two path levels, so Copilot truncates it and may ground answers on unrelated sibling documentation.", "location": { - "file": "src/CustomerExt.TableExt.al", - "line": 8 + "file": "app.json", + "line": 12 }, "references": [ - { "path": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md" } + { "path": "microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md" } ], "confidence": "high", "domain": "AppSource", - "suggested-code": "field(50100; \"Loyalty Points ABC\"; Integer)" + "suggested-code": "\"help\": \"https://contoso.com/docs/myapp\"" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md index 1ddd810..cf962ba 100644 --- a/microsoft/skills/review/al-breaking-changes-review.md +++ b/microsoft/skills/review/al-breaking-changes-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 codeunits, tables, and table extensions that expose procedures, fields, or events to other apps, and any member whose access is being widened. - The changed procedures, fields, and triggers, weighted toward non-`local` procedures, published table fields, event publishers, and any member whose signature, access modifier, or obsolete state is being altered. -- Tokens extracted from the diff that relate to API stability and deprecation (`signature`, `parameter`, `return`, `var`, `Obsolete`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `Pending`, `Removed`, `CLEAN`, `SecretText`, `token`, `internal`, `local`, `public`, `protected`, `Scope`, `namespace`, `using`, `AS0007`). +- Tokens extracted from the diff that relate to API stability and deprecation (`signature`, `parameter`, `return`, `var`, `Obsolete`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `Pending`, `Removed`, `CLEAN`, `SecretText`, `token`, `internal`, `local`, `public`, `protected`, `Scope`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. @@ -50,8 +50,7 @@ The following targeted checks cover every current `breaking-changes` article: - A published procedure changes parameter count/order/type/name, `var`, return type, or array shape instead of preserving the old signature and adding an overload — `do-not-change-published-procedure-signatures`. - A public procedure/event/interface exposes a credential or other sensitive value through `Text` or an externally callable contract — `do-not-expose-sensitive-data-through-public-api`. - Code already marked obsolete is expanded with new behavior instead of routing new callers to its replacement — `do-not-modify-code-already-marked-obsolete`. -- A shipped table field is deleted, renamed, renumbered, or replaced without retaining the original field as `ObsoleteState = Pending` and migrating its data — `obsolete-table-fields-instead-of-deleting-them`. This owns AS0005 field-name changes; do not substitute the namespace article. -- A published object's namespace changes between the base and changed source while its identity otherwise remains — `namespace-is-part-of-published-object-identity`. Do not apply it to a new, unshipped object or to an ordinary object-name change with no namespace change. +- A shipped table field is deleted, renamed, renumbered, or replaced without retaining the original field as `ObsoleteState = Pending` and migrating its data — `obsolete-table-fields-instead-of-deleting-them`. For `obsolete-table-fields-instead-of-deleting-them`, compare the baseline ID and name before emitting. When the original field remains under the same ID and name with `ObsoleteState = Pending`, and the replacement uses a new ID, the change follows the rule and must not be flagged. diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index 223fbbd..6c8e63c 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -16,7 +16,7 @@ application-area: [all] 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. +Style findings cover AL conventions that require contextual judgment — API page naming, temporary-variable prefixes, label semantics, named invocations, `FieldCaption`/`TableCaption` in user messages, error-parameter handling, and file naming. Mechanical compiler and analyzer rules are intentionally outside this skill; run the consuming app's configured analyzers separately. An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. @@ -40,8 +40,8 @@ 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. 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`). +- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, error-handling call sites, and API declarations. +- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `StrSubstNo`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object or declaration. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. @@ -50,8 +50,6 @@ Do not worklist `temporary-variable-temp-prefix.md` for an event publisher param Apply these high-signal mappings before fuzzy topic ranking: - A `Label` or `TextConst` contains multiple or ambiguous placeholders but has no `Comment`, or its Comment does not explain every placeholder — `label-comment-explains-placeholders.md`. A single placeholder whose meaning is explicit in the text, such as `Customer %1`, is allowed without a Comment and must not be flagged. -- `function-call-parentheses-required.md` applies only to a zero-argument invocation written without `()`. Never worklist it from an invocation that already has parentheses or supplies arguments, including `Error(Label, Arg1, Arg2)`. - 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. @@ -60,7 +58,7 @@ When the post-conflict worklist is empty because no applicable style knowledge e 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`. -Severity calibration — a formal analyzer already flags the mechanical presence/naming conventions (the `this` keyword AA0248, approved label suffixes AA0074, variable-declaration order by type AA0021, a missing `ToolTip`, required parentheses). On those, BCQuality's value is the *explanation* of why the rule exists, not a second gate; emit them at `info` so a consumer that gates on severity does not re-flag what CodeCop/AppSourceCop already reports. Reserve `minor` for style issues with concrete downstream impact the analyzer does not catch — lost translation or telemetry classification from a string-built `Error`, an `OptionCaption` that does not match its `OptionMembers`, or a misleading named invocation. A procedure-local `Label` is valid and is not a correctness or localization finding; an explicit repository preference for object scope is at most low-severity maintainability guidance. This keeps the domain's default output advisory and prevents analyzer-redundant noise from competing with substantive review. +Severity calibration — reserve `minor` for style issues with concrete downstream impact that deterministic tooling does not establish, such as lost translation or telemetry classification from a string-built `Error` or a misleading named invocation. A procedure-local `Label` is valid and is not a correctness or localization finding; an explicit repository preference for object scope is at most low-severity maintainability guidance. Do not rediscover or report mechanical compiler or analyzer diagnostics, even at `info`. Set `confidence` to: @@ -70,7 +68,7 @@ Set `confidence` to: After evaluating each worklist entry, also consider whether the diff exhibits a style defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a clear, widely-accepted AL style violation with a concrete basis a knowledgeable BC reviewer would agree on — steelman it first and drop personal preference, speculation, and any single defensible formatting choice among several; when in doubt, omit. The scope is strictly style — naming, labelling, formatting, and analyzer-adjacent conventions. A correctness, logic, data-integrity, or contract defect is NOT a style finding even when it can be reworded as a convention: a method that mutates a shared `Record`'s filters, an unfiltered `DeleteAll`, a violated interface contract, or a wrong boolean guard are behavioural defects, not conventions — do not emit them here under a style framing. If a specific domain leaf covers the concern (performance, security, error-handling, …) it belongs there; if no knowledge file in any domain covers it, it belongs to the `al-code-review` super-skill's cross-cutting self-review agent channel (`from-sub-skill: "agent"`, `severity` capped at `minor`), not to this leaf. A reliable test: if you cannot cite a style `## Best Practice`/`## Anti Pattern` for the concern, it is very likely not a style finding. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. -For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add a missing contextual `ToolTip`, replace a string-concatenated `Error` with a Label-backed call, or correct an API naming property whose intended value is clear). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. @@ -91,20 +89,20 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s "skill": { "id": "al-style-review", "version": 1 }, "outcome": "completed", "summary": { - "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 1 }, + "counts": { "blocker": 0, "major": 0, "minor": 1, "info": 0 }, "coverage": { "worklist-size": 1, "items-evaluated": 1 } }, "findings": [ { - "id": "microsoft/knowledge/style/label-suffix-approved-list.md", - "severity": "info", - "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.", + "id": "microsoft/knowledge/style/label-comment-explains-placeholders.md", + "severity": "minor", + "message": "The label has two ambiguous placeholders but no Comment explaining what each value represents to translators.", "location": { "file": "src/Sales/PostingRoutines.Codeunit.al", "line": 42 }, "references": [ - { "path": "microsoft/knowledge/style/label-suffix-approved-list.md" } + { "path": "microsoft/knowledge/style/label-comment-explains-placeholders.md" } ], "confidence": "high", "domain": "Style" From 51597068b1bc2ac6dda10d3bb1103b0d7da4f4bd Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Fri, 11 Sep 2026 12:35:31 +0200 Subject: [PATCH 70/86] Add bounded knowledge retrieval (#179) * Add bounded knowledge retrieval Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d8b7764-f15a-49ea-8d50-d9147334f8af * Fix bc-version overflow and pathless-row identity in bounded retrieval Catalog matching compared an Int32 -BCVersion against a bigint range bound. PowerShell coerces the right operand to the left operand's type, so a bound wider than Int32 threw a conversion error and failed the whole domain catalog rather than the single row. Metadata validation already accepts such bounds, so compare as bigint on both sides. The shared pager built its oversized-row message with $row.path, which throws under Set-StrictMode -Version Latest when a row carries no path, replacing the explicit bound failure with a property-lookup error. Resolve the path defensively for dictionary and object rows so the offset-based fallback is reachable. Both paths gain regression coverage that fails without these fixes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d8b7764-f15a-49ea-8d50-d9147334f8af --- .github/scripts/Test-KnowledgeIndex.ps1 | 3 + .../skills/review/al-appsource-review.md | 2 +- .../review/al-breaking-changes-review.md | 2 +- microsoft/skills/review/al-code-review.md | 14 +- .../skills/review/al-data-modeling-review.md | 2 +- .../skills/review/al-error-handling-review.md | 2 +- microsoft/skills/review/al-events-review.md | 2 +- .../skills/review/al-interfaces-review.md | 2 +- .../skills/review/al-performance-review.md | 2 +- microsoft/skills/review/al-privacy-review.md | 2 +- microsoft/skills/review/al-query-review.md | 2 +- microsoft/skills/review/al-security-review.md | 2 +- microsoft/skills/review/al-style-review.md | 2 +- .../skills/review/al-telemetry-review.md | 2 +- microsoft/skills/review/al-testing-review.md | 2 +- microsoft/skills/review/al-ui-review.md | 2 +- microsoft/skills/review/al-upgrade-review.md | 2 +- .../skills/review/al-web-services-review.md | 2 +- skills/do.md | 30 + skills/read.md | 55 ++ tools/Bounded-Results.ps1 | 117 +++ tools/Build-KnowledgeIndex.ps1 | 178 +++- tools/Get-KnowledgeArticles.ps1 | 187 +++++ tools/Knowledge-Retrieval.ps1 | 298 +++++++ tools/Search-Knowledge.ps1 | 244 ++++++ tools/Test-KnowledgeRetrieval.ps1 | 788 ++++++++++++++++++ 26 files changed, 1891 insertions(+), 55 deletions(-) create mode 100644 tools/Bounded-Results.ps1 create mode 100644 tools/Get-KnowledgeArticles.ps1 create mode 100644 tools/Knowledge-Retrieval.ps1 create mode 100644 tools/Search-Knowledge.ps1 create mode 100644 tools/Test-KnowledgeRetrieval.ps1 diff --git a/.github/scripts/Test-KnowledgeIndex.ps1 b/.github/scripts/Test-KnowledgeIndex.ps1 index 76896af..1e61064 100644 --- a/.github/scripts/Test-KnowledgeIndex.ps1 +++ b/.github/scripts/Test-KnowledgeIndex.ps1 @@ -16,6 +16,8 @@ 3. Selection-input integrity — every parsed article row carries the non-empty `domain` + `keywords` the worklist predicate selects on, and every article parses (an unparseable article is an invalid file). + 4. Bounded retrieval — delegates to tools/Test-KnowledgeRetrieval.ps1 for + lossless paging, exact-body round trips, and explicit failure cases. Exit code 0 = healthy; non-zero = a problem CI must block on. #> @@ -90,4 +92,5 @@ if ($problems.Count) { exit 1 } Write-Host "Knowledge-index check PASSED: $($rows.Count) articles, deterministic, full coverage, selection inputs intact." -ForegroundColor Green +& (Join-Path $Root 'tools/Test-KnowledgeRetrieval.ps1') -Root $Root exit 0 diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md index ebbed36..c851ea2 100644 --- a/microsoft/skills/review/al-appsource-review.md +++ b/microsoft/skills/review/al-appsource-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `appsource` as this skill's candidate set across every enabled Microsoft, community, and custom layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/appsource/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain appsource`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md index cf962ba..7f30713 100644 --- a/microsoft/skills/review/al-breaking-changes-review.md +++ b/microsoft/skills/review/al-breaking-changes-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `breaking-changes` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/breaking-changes/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain breaking-changes`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 9972aca..6a577ba 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -65,7 +65,10 @@ The worklist is the list of sub-skills judged relevant by the previous step. Eve The Action step consists of **discrete leaf invocations**, not one combined generation. Invocation scheduling belongs to the orchestrator: independent leaves may run serially or concurrently, but their evaluation contexts and findings-reports remain isolated. Concretely this means: -- **Isolate leaf invocations when the host supports it.** For fast/small models, each sub-skill SHOULD run in a fresh model call or child context containing only the task input, READ/DO contracts, the leaf instructions, a domain-filtered slice of the current knowledge index, and articles that leaf worklists. Preserve each index row's exact `path`; the leaf must copy references from that slice. The coordinator then collects the resulting JSON. This is the preferred fast-model profile: it bounds context, prevents later leaves from being skipped as attention is exhausted, and removes any reason to synthesize article paths. +- **Isolate leaf invocations when the host supports it.** Each sub-skill SHOULD run in a fresh model call or child context containing only its assigned source paths, READ/DO contracts, the leaf instructions, the complete bounded domain catalog per READ, and articles that leaf worklists. Preserve each catalog row's exact `path`; the leaf must copy references from that catalog. +- **Keep run artifacts private.** Before dispatch, allocate a new GUID-named directory under the current session's artifact directory and a distinct scratch/report child directory for every leaf. Pass a leaf only its own assigned source paths and child directory, never the run root or sibling paths. A leaf MUST NOT discover, enumerate, read, modify, or delete sibling artifacts. Do not reuse a prior run directory, and do not clean up any run artifact until every leaf has finished and consolidation is complete. +- **Use the exact Task return as the report.** Capture each leaf's exact return as the primary transport and apply DO's consumer acceptance gate before rollup. Worker-side persistence of the same report in its private directory is optional and redundant; a missing report file does not invalidate an otherwise valid exact return. +- **Treat automatic output spills as host-owned.** If the host reports that a Task return was automatically spilled, the coordinator MAY read that file read-only only at the exact path returned by the tool. Never modify, delete, enumerate around, or reuse an automatic spill path. Never bypass a content-exclusion or access denial. - Treat each sub-skill in the worklist as its own pass: read the sub-skill's instructions, apply its Source → Relevance → Worklist → Action steps to the orchestrator-supplied inputs, and produce that sub-skill's complete findings-report independently. - Do not collapse multiple sub-skills into one shared reasoning step. Each sub-skill has a distinct knowledge subset and a distinct evaluation procedure; sharing one rolled-up scan dilutes per-skill attention and causes leaves to silently underreport (this has been observed in production: leaf skills returned empty `findings[]` while their standalone runs against the same diff produced multiple matches). - The agent self-review pass is its own final iteration. Begin it only after every sub-skill in the worklist has completed and its sub-result is recorded. @@ -77,8 +80,8 @@ The Action step consists of **discrete leaf invocations**, not one combined gene 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`. -2. Capture the sub-skill's complete findings-report verbatim and append it to `sub-results`. -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`. +2. Capture the exact Task return and validate it against DO's consumer acceptance gate before accepting it. Preserve an invalid raw return unchanged in the leaf's private artifacts or host log; do not reconstruct or repair it. Record a separate failed validation result with no findings for rollup. +3. Append the accepted findings-report, or the separate failed validation result, to `sub-results`. If its `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, compare each entry from the sub-skill's `findings[]` with findings already rolled up. Two findings are duplicates when they point to the same file and overlapping line/range and prescribe materially the same correction, even when their knowledge-file IDs differ. Merge duplicates instead of appending both: keep the more specific domain owner, preserve that finding's optional `domain` field verbatim (including its absence), use its reference as `references[0]` and therefore as `id`, append the other references as supporting references, keep the highest severity and confidence justified by either report, and preserve one self-contained message. Article and leaf ownership notes decide specificity; do not choose by execution order. 5. Append each non-duplicate finding, setting `from-sub-skill` to the sub-skill's `skill.id` and preserving its optional `domain` field verbatim, including its absence. 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. @@ -122,7 +125,10 @@ Calculate `summary.counts` from the final top-level `findings[]`, after failed s Derive `outcome` using the DO rollup rules. `outcome-reason` is populated for `partial` and `failed` and SHOULD summarize per-sub-skill state, for example: *"al-security-review failed (tool timeout); al-performance-review completed."* -Before emitting the rollup, apply DO's reference-integrity gate to every nested and top-level finding. Every knowledge-backed ID/reference path must exist in the live checkout, must have been opened by the producing leaf, and must be copied verbatim rather than synthesized. Treat a sub-result containing an unverifiable citation as failed and exclude its findings from the top-level rollup. +Before emitting the rollup, apply DO's consumer acceptance gate to every nested +and top-level finding. Treat an invalid sub-result as failed and exclude all of +its findings from the top-level rollup. Preserve its exact raw payload +separately; never reconstruct it into a success-shaped report. ## Output diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 01a983a..05dcd0b 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `data-modeling` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/data-modeling/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain data-modeling`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index 63c4d5e..56bc0fe 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `error-handling` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/error-handling/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain error-handling`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index 559d036..68bcdb0 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `events` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/events/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain events`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-interfaces-review.md b/microsoft/skills/review/al-interfaces-review.md index 885cd0b..f5d65cd 100644 --- a/microsoft/skills/review/al-interfaces-review.md +++ b/microsoft/skills/review/al-interfaces-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `interfaces` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/interfaces/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain interfaces`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index f2fa80d..664f20d 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `performance` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/performance/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain performance`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index 469000c..17b4e7b 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `privacy` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/privacy/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain privacy`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-query-review.md b/microsoft/skills/review/al-query-review.md index 3681b23..c52ddd8 100644 --- a/microsoft/skills/review/al-query-review.md +++ b/microsoft/skills/review/al-query-review.md @@ -18,7 +18,7 @@ Reviews AL source changes against the `query` knowledge domain in BCQuality. Thi ## Source -Read `knowledge-index.json` once and take entries whose `domain` is `query` across enabled layers. Open an article body only after it enters the Worklist. If the index is unavailable, discover `*/knowledge/query/*.md` by path. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain query`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index e3e4049..00e8d10 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `security` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/security/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain security`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index 6c8e63c..2703c87 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -22,7 +22,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `style` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/style/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain style`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-telemetry-review.md b/microsoft/skills/review/al-telemetry-review.md index 4b50a9e..1c2046d 100644 --- a/microsoft/skills/review/al-telemetry-review.md +++ b/microsoft/skills/review/al-telemetry-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `telemetry` as this skill's candidate set across every enabled Microsoft, community, and custom layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/telemetry/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain telemetry`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index ed50c46..c96ac83 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `testing` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/testing/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain testing`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index c04a30a..8ffd731 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -22,7 +22,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `ui` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/ui/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain ui`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md index 2bb1877..94851a3 100644 --- a/microsoft/skills/review/al-upgrade-review.md +++ b/microsoft/skills/review/al-upgrade-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `upgrade` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/upgrade/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain upgrade`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md index e818e46..19d0735 100644 --- a/microsoft/skills/review/al-web-services-review.md +++ b/microsoft/skills/review/al-web-services-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `web-services` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/web-services/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain web-services`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/skills/do.md b/skills/do.md index 4ac433b..f86509b 100644 --- a/skills/do.md +++ b/skills/do.md @@ -159,6 +159,36 @@ The emitted document MUST be strict, valid JSON per [RFC 8259](https://www.rfc-e AL source is the common failure case. Quoted identifiers (for example `Rec."No."`) and multi-line snippets routinely appear in `message`, `suggested-code`, and `suggested-code-omission-reason`, and each embedded quote or newline MUST be escaped when placed in a string value. A `suggested-code` payload that spans several lines is a single JSON string with `\n` separators, not a literal multi-line block. Emit the document as one JSON value with no trailing commentary, and do not rely on the consumer to repair unescaped output. +### Consumer acceptance gate + +The exact action-skill return is the primary report transport. Before accepting +it as a findings-report, a coordinator or host MUST validate it +deterministically: + +1. Parse the exact return as strict JSON and validate every required field, + enum, type, conditional requirement, summary count, coverage value, and + leaf/super-skill constraint against this output contract. +2. For every knowledge-backed finding, verify each `references[].path` is an + exact repo-relative knowledge path that exists in the live BCQuality + snapshot, and verify `findings[].id` exactly equals + `references[0].path`. Verify each path is also present in the coordinator's + recorded set of complete article bodies retrieved for that leaf; catalog + membership alone is insufficient. Keep optional `references[].sha` + separate: it is commit provenance, not an article content hash. +3. For every `location`, verify `file` is an exact source path in the supplied + review scope, the file exists in that source snapshot, and `line` and any + inclusive range identify existing lines with `start-line == line` and + `end-line >= start-line`. + +Validation failure invalidates the complete return; consumers MUST NOT salvage +individual findings, infer missing fields, reconstruct JSON, clamp ranges, +rewrite paths, or otherwise silently repair model output. Preserve the invalid +raw payload unchanged in private run artifacts or host logs. Record a separate +failed validation result for that leaf with no findings, and derive the +super-skill outcome as `partial` or `failed` using the normal rollup rules. +Worker-side report-file persistence is optional and never replaces validation +of the exact return. + ### Field semantics **`outcome`** (required) — diff --git a/skills/read.md b/skills/read.md index f2e9c1e..dddd7db 100644 --- a/skills/read.md +++ b/skills/read.md @@ -149,3 +149,58 @@ The standard workflow for finding applicable files: 4. Resolve conflicts via layer precedence. Steps 1–3 are deterministic; step 4 is applied only when conflicts are detected. + +### Bounded retrieval for review skills + +Resolve `$root` to the BCQuality root, not the reviewed source. Entry prepares +the index once before dispatch; that prepared index is the catalog snapshot and +leaves use it read-only. Catalog retrieval validates the complete index metadata +and returned paths without reopening or rehashing article bodies. Post-Entry +body changes therefore take effect only after Entry rebuilds the index; exact +body retrieval rejects a selected article whose content hash differs from its +prepared row. In one PowerShell tool session, invoke the helpers with `&` so +array arguments remain arrays: + +```powershell +& (Join-Path $root 'tools\Search-Knowledge.ps1') -Domain $domain -Technologies @('al') +& (Join-Path $root 'tools\Get-KnowledgeArticles.ps1') -Paths @($exactPath) +``` + +Pass enabled layers and only task dimensions that are actually known. Catalog +retrieval returns every domain and READ-applicable row: it does not rank, +sample, apply top-k, deduplicate by basename, or omit rows based on query text. +Consume every page by passing `continuation.offset` as `-Offset` and +`continuation.snapshot` as `-Snapshot` with the unchanged request until +`complete` is `true`. Each page repeats request context, defaults, and totals. +An omitted applicability field on a row inherits that page's `defaults`; it +does not mean unknown task context. Preserve every row's exact `path`, `layer`, +complete `keywords`, `title`, one-line `description`, non-default applicability +fields, explicit `applicability`, and `unknownDimensions`. + +Apply the leaf's existing Relevance and Worklist to the complete catalog union. +Split the resulting exact paths into stable chunks of at most eight; never pass +more paths than `-MaxArticles` (whose maximum is eight). Request article bodies +only by one such chunk. Consume every +returned `body`, then request `remainingPaths` with +`continuation.snapshot` as `-Snapshot` until `complete` is `true`, preserving +the other request settings. Continuation is confined to that chunk. Bodies are +original strict UTF-8 text with source byte counts and SHA-256 content hashes; +they are never summarized or truncated. Samples are not loaded unless +requested explicitly with `-Samples` and exact sibling paths; their sibling +article must match its prepared hash and contain the exact READ link. + +The default serialized response limit is 16,000 bytes including its output +newline. Never combine pages or bodies into an unbounded prompt. A malformed or +internally inconsistent prepared index, changed continuation snapshot, selected +article hash mismatch, invalid continuation, unsafe or missing path, invalid +UTF-8, broken sample link, oversized path chunk, or row/envelope that cannot fit +fails explicitly. Entry is the only index preparation point: a leaf does not +rebuild. If PowerShell, a helper, or a valid prepared index is unavailable, +discover exact paths across the enabled domain folders and use native bounded +reads through EOF, validating frontmatter per READ and never treating retrieval +failure as an empty result. + +The helpers' `sha256` and `bytes` fields describe the retrieved file content. +They are not citation provenance. Optional findings `references[].sha` is the +BCQuality commit SHA the skill reviewed; omit it when that provenance is not +available or would misrepresent uncommitted content. diff --git a/tools/Bounded-Results.ps1 b/tools/Bounded-Results.ps1 new file mode 100644 index 0000000..6def944 --- /dev/null +++ b/tools/Bounded-Results.ps1 @@ -0,0 +1,117 @@ +# Shared deterministic paging. Callers build the complete immutable result first. +#requires -Version 7.2 +Set-StrictMode -Version Latest + +function Get-ResultSnapshot { + param([Parameter(Mandatory)] $Value) + + $json = ConvertTo-Json -InputObject $Value -Depth 30 -Compress + return [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($json)) + ).ToLowerInvariant() +} + +function Get-SerializedByteCount { + param([Parameter(Mandatory)] [string] $Json) + + # PowerShell writes one platform newline after the returned JSON string. + return [Text.Encoding]::UTF8.GetByteCount($Json) + + [Text.Encoding]::UTF8.GetByteCount([Environment]::NewLine) +} + +function ConvertTo-BoundedPage { + param( + [Parameter(Mandatory)] [Collections.IDictionary] $Header, + [Parameter(Mandatory)] [Collections.IDictionary] $Groups, + [ValidateRange(0, 2147483647)] [int] $Offset = 0, + [string] $Snapshot, + [ValidateRange(1024, 16000)] [int] $MaxBytes = 16000 + ) + + $total = 0 + foreach ($name in $Groups.Keys) { + $total += $Groups[$name].Count + } + if (($total -eq 0 -and $Offset -ne 0) -or ($total -gt 0 -and $Offset -ge $total)) { + throw "Invalid Offset=$Offset for totalCount=$total; no rows were returned." + } + if ($Offset -gt 0 -and -not $Snapshot) { + throw 'Continuation requires Snapshot from the preceding page.' + } + if ($Snapshot -and $Snapshot -cne $Header.snapshot) { + throw 'Snapshot changed or continuation belongs to another request. Discard partial results and restart at Offset=0.' + } + + $page = [ordered]@{} + foreach ($key in $Header.Keys) { + $page[$key] = $Header[$key] + } + $page.offset = $Offset + $page.returnedCount = 0 + $page.totalCount = $total + $page.remainingCount = $total - $Offset + $page.complete = ($total -eq 0) + $page.continuation = if ($total) { + [ordered]@{ offset = $Offset; snapshot = $Header.snapshot } + } + else { + $null + } + foreach ($name in $Groups.Keys) { + $page[$name] = [Collections.Generic.List[object]]::new() + } + + $json = ConvertTo-Json -InputObject $page -Depth 30 -Compress + if ((Get-SerializedByteCount -Json $json) -gt $MaxBytes) { + throw "Page envelope exceeds MaxBytes=$MaxBytes. Use READ's path-discovery fallback; never truncate." + } + + $position = 0 + foreach ($name in $Groups.Keys) { + foreach ($row in $Groups[$name]) { + if ($position++ -lt $Offset) { + continue + } + + $page[$name].Add($row) + $page.returnedCount++ + $page.remainingCount-- + $page.complete = ($page.remainingCount -eq 0) + $page.continuation = if ($page.complete) { + $null + } + else { + [ordered]@{ + offset = $Offset + $page.returnedCount + snapshot = $Header.snapshot + } + } + + $next = ConvertTo-Json -InputObject $page -Depth 30 -Compress + if ((Get-SerializedByteCount -Json $next) -gt $MaxBytes) { + $page[$name].RemoveAt($page[$name].Count - 1) + $page.returnedCount-- + $page.remainingCount++ + $page.complete = $false + $page.continuation = [ordered]@{ + offset = $Offset + $page.returnedCount + snapshot = $Header.snapshot + } + if ($page.returnedCount -eq 0) { + $rowPath = $null + if ($row -is [Collections.IDictionary]) { + if ($row.Contains('path')) { $rowPath = $row['path'] } + } + elseif ($null -ne $row -and $row.PSObject.Properties['path']) { + $rowPath = $row.PSObject.Properties['path'].Value + } + $identity = if ($rowPath) { " at $rowPath" } else { " at Offset=$Offset" } + throw "One complete $name row plus envelope exceeds MaxBytes=$MaxBytes$identity. No row was clipped." + } + return $json + } + $json = $next + } + } + return $json +} diff --git a/tools/Build-KnowledgeIndex.ps1 b/tools/Build-KnowledgeIndex.ps1 index 5835e5d..d246ff3 100644 --- a/tools/Build-KnowledgeIndex.ps1 +++ b/tools/Build-KnowledgeIndex.ps1 @@ -30,6 +30,8 @@ expected to prune its clone to policy first). For provenance and to reproduce a consumer's exact view, pass -EnabledLayers to restrict the walk to those layers and to record the policy in the index header. + Invalid articles are omitted with a path-specific warning so one bad + optional layer article cannot block valid siblings. .PARAMETER BCQualityRoot Path to the BCQuality content root to index (typically a filtered clone). @@ -69,6 +71,17 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1') + +if ($PSBoundParameters.ContainsKey('EnabledLayers')) { + if ($null -eq $EnabledLayers) { + throw 'EnabledLayers must be an array; omit it to index all layers.' + } + if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or + @($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) { + throw 'EnabledLayers must contain unique canonical lowercase layer names.' + } +} # Default to the clone root (parent of this script's tools/ folder) so the # agent's Entry preparation step can invoke this with no arguments from the @@ -93,6 +106,45 @@ function Get-RelativePath { return ($rel -replace '\\', '/') } +function Get-BytesSha256 { + param([byte[]] $Bytes) + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant() + } + finally { + $sha.Dispose() + } +} + +function Read-ArticleSource { + param([string] $Path) + $bytes = [IO.File]::ReadAllBytes($Path) + try { + $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) + } + catch [Text.DecoderFallbackException] { + throw [IO.InvalidDataException]::new('invalid UTF-8', $_.Exception) + } + return [pscustomobject]@{ + bytes = $bytes + text = $text + sha256 = Get-BytesSha256 -Bytes $bytes + } +} + +function Get-ValueSha256 { + param([Parameter(Mandatory)] $Value) + $bytes = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject $Value -Depth 8 -Compress)) + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-', '').ToLowerInvariant() + } + finally { + $sha.Dispose() + } +} + # Trims a Description to a single short line (<= $Max chars) for the lean # index. Takes the first sentence; truncates on a word boundary if still long. function Get-LeanDescription { @@ -116,9 +168,12 @@ function ConvertFrom-ArticleFrontmatter { # Pattern) is included; the index is a lossless substitute for the # frontmatter + Description the worklist predicate reads, not a # substitute for the article's normative guidance. - param([string] $Path) + param( + [string] $Path, + [string] $Text + ) - $lines = Get-Content -LiteralPath $Path -ErrorAction Stop + $lines = [regex]::Split($Text.TrimStart([char]0xfeff), '\r\n|\n|\r') # Frontmatter is the first '---'-delimited block. if ($lines.Count -lt 1 -or $lines[0].Trim() -ne '---') { return $null } @@ -129,19 +184,42 @@ function ConvertFrom-ArticleFrontmatter { if ($fmEnd -lt 0) { return $null } $fm = @{} + $arrayFields = @('bc-version', 'keywords', 'technologies', 'countries', 'application-area') for ($i = 1; $i -lt $fmEnd; $i++) { $line = $lines[$i] if ($line -match '^\s*([a-zA-Z][\w-]*)\s*:\s*(.*)$') { $key = $Matches[1] $val = $Matches[2].Trim() - if ($val -match '^\[(.*)\]$') { + if ($key -in $arrayFields) { + if ($val -notmatch '^\[(.*)\]$') { + throw [IO.InvalidDataException]::new( + "frontmatter field '$key' must use non-empty bracket-array syntax" + ) + } $inner = $Matches[1].Trim() - if ($inner -eq '') { $fm[$key] = @() } - else { $fm[$key] = @($inner -split '\s*,\s*' | ForEach-Object { $_.Trim() }) } + if ($inner -eq '') { + throw [IO.InvalidDataException]::new( + "frontmatter field '$key' must use non-empty bracket-array syntax" + ) + } + $values = @($inner -split '\s*,\s*' | ForEach-Object { $_.Trim() }) + if (@($values | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count) { + throw [IO.InvalidDataException]::new( + "frontmatter field '$key' must use non-empty bracket-array syntax" + ) + } + $fm[$key] = $values } elseif ($val -ne '') { $fm[$key] = $val } } } + foreach ($field in $arrayFields) { + if (-not $fm.ContainsKey($field) -or $fm[$field] -isnot [array] -or -not $fm[$field].Count) { + throw [IO.InvalidDataException]::new( + "frontmatter field '$field' must use non-empty bracket-array syntax" + ) + } + } # Body parsing: H1 title and the full Description section. The Description # is the article's primary retrieval target per READ and is captured @@ -185,42 +263,71 @@ $indexArticles = [System.Collections.Generic.List[object]]::new() foreach ($layerDir in @('microsoft', 'community', 'custom')) { $kbRoot = Join-Path $BCQualityRoot (Join-Path $layerDir 'knowledge') if (-not (Test-Path $kbRoot)) { continue } - if ($EnabledLayers -and ($EnabledLayers -notcontains $layerDir)) { continue } + if ($EnabledLayers -and ($EnabledLayers -cnotcontains $layerDir)) { continue } - Get-ChildItem -LiteralPath $kbRoot -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue | - Sort-Object FullName | - ForEach-Object { - $rel = Get-RelativePath -Root $BCQualityRoot -Full $_.FullName - $parsed = $null - try { $parsed = ConvertFrom-ArticleFrontmatter -Path $_.FullName } catch { $parsed = $null } + $files = @( + Get-ChildItem -LiteralPath $kbRoot -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue | + Sort-Object FullName + ) + foreach ($file in $files) { + $rel = Get-RelativePath -Root $BCQualityRoot -Full $file.FullName + try { + $source = Read-ArticleSource -Path $file.FullName + $parsed = ConvertFrom-ArticleFrontmatter -Path $file.FullName -Text $source.text if (-not $parsed) { - # Invalid/unparseable file: list path + domain-from-path so it - # is never silently dropped from discovery. Consumers fall back - # to reading it in full. - $domainFromPath = if ($rel -match '/knowledge/([^/]+)/') { $Matches[1] } else { '' } - $indexArticles.Add([pscustomobject]@{ - path = $rel; layer = $layerDir; domain = $domainFromPath - 'bc-version' = @(); technologies = @(); countries = @(); 'application-area' = @() - keywords = @(); title = ''; description = ''; parsed = $false - }) | Out-Null - return + throw [IO.InvalidDataException]::new('missing or unterminated frontmatter') + } + foreach ($required in @( + @('domain', $parsed.domain), + @('H1 title', $parsed.title), + @('Description', $parsed.description) + )) { + if ([string]::IsNullOrWhiteSpace([string]$required[1])) { + throw [IO.InvalidDataException]::new("missing $($required[0])") + } } - $indexArticles.Add([pscustomobject]@{ - path = $rel - layer = $layerDir - domain = $parsed.domain - 'bc-version' = @($parsed.'bc-version') - technologies = @($parsed.technologies) - countries = @($parsed.countries) - 'application-area' = @($parsed.'application-area') - keywords = @($parsed.keywords) - title = $parsed.title - description = if ($FullIndex) { $parsed.description } else { Get-LeanDescription -Text $parsed.description } - parsed = $true - }) | Out-Null } + catch [IO.InvalidDataException] { + Write-Warning "Skipping invalid knowledge article '$rel': $($_.Exception.Message)." + continue + } + + $article = [ordered]@{ + path = $rel + layer = $layerDir + domain = $parsed.domain + 'bc-version' = @($parsed.'bc-version') + technologies = @($parsed.technologies) + countries = @($parsed.countries) + 'application-area' = @($parsed.'application-area') + keywords = @($parsed.keywords) + title = $parsed.title + description = if ($FullIndex) { $parsed.description } else { Get-LeanDescription -Text $parsed.description } + parsed = $true + sourceSha256 = $source.sha256 + } + $problem = Get-KnowledgeMetadataProblem -Row $article + if ($problem) { + Write-Warning "Skipping invalid knowledge article '$rel': $problem." + continue + } + $indexArticles.Add($article) | Out-Null + } } +$articlesByPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) +foreach ($article in $indexArticles) { + if (-not $articlesByPath.TryAdd($article.path, $article)) { + throw "Duplicate knowledge path while building source snapshot: $($article.path)" + } +} +$sourcePaths = [string[]]@($articlesByPath.Keys) +[Array]::Sort($sourcePaths, [StringComparer]::Ordinal) +$sourceManifest = @( + foreach ($path in $sourcePaths) { + [ordered]@{ path = $path; sha256 = $articlesByPath[$path].sourceSha256 } + } +) $index = [pscustomobject]@{ version = 1 generatedAt = (Get-Date).ToUniversalTime().ToString('o') @@ -228,6 +335,7 @@ $index = [pscustomobject]@{ knowledgeAllow= @($KnowledgeAllow) knowledgeDeny = @($KnowledgeDeny) articleCount = $indexArticles.Count + sourceSnapshot= Get-ValueSha256 -Value $sourceManifest articles = @($indexArticles) } diff --git a/tools/Get-KnowledgeArticles.ps1 b/tools/Get-KnowledgeArticles.ps1 new file mode 100644 index 0000000..cdd112a --- /dev/null +++ b/tools/Get-KnowledgeArticles.ps1 @@ -0,0 +1,187 @@ +<# +.SYNOPSIS + Reads a bounded prefix of exact article or sample paths without altering bodies. +.DESCRIPTION + The UTF-8 byte size bound covers the complete serialized JSON plus its output + newline. A body that cannot fit fails explicitly; it is never summarized or + truncated. Samples are loaded only with -Samples and must be linked by their + sibling article using READ's exact link convention. +#> +#requires -Version 7.2 +[CmdletBinding()] +param( + [ValidateNotNullOrEmpty()] [string] $BCQualityRoot = (Split-Path $PSScriptRoot -Parent), + [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string[]] $Paths, + [ValidateRange(1, 8)] [int] $MaxArticles = 8, + [ValidateRange(1024, 16000)] [int] $MaxBytes = 16000, + [ValidateSet('microsoft', 'community', 'custom')] + [AllowEmptyCollection()] [string[]] $EnabledLayers = @('microsoft', 'community', 'custom'), + [string] $IndexPath, + [ValidatePattern('^[a-f0-9]{64}$')] [string] $Snapshot, + [switch] $Samples +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1') +. (Join-Path $PSScriptRoot 'Bounded-Results.ps1') + +$BCQualityRoot = Resolve-KnowledgeRoot $BCQualityRoot +if (-not $IndexPath) { + $IndexPath = Join-Path $BCQualityRoot 'knowledge-index.json' +} +if ($Paths.Count -gt $MaxArticles) { + throw "Paths count $($Paths.Count) exceeds MaxArticles=$MaxArticles. Split the worklist into stable chunks of at most $MaxArticles exact paths." +} +if ($null -eq $EnabledLayers) { + throw 'EnabledLayers must be an array.' +} +if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or + @($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) { + throw 'EnabledLayers must contain unique canonical lowercase layer names.' +} + +$recovery = "Run Entry preparation once before dispatch, or use READ's bounded native-file fallback. Do not rebuild in a leaf." +$preparedIndex = Read-PreparedKnowledgeIndex -IndexPath $IndexPath -Recovery $recovery +$index = $preparedIndex.index +$byPath = $preparedIndex.byPath +$unrestricted = $index.enabledLayers.Count -eq 0 -or + ($index.enabledLayers.Count -eq 1 -and $null -eq $index.enabledLayers[0]) +$indexedLayers = @( + if ($unrestricted) { 'microsoft', 'community', 'custom' } else { $index.enabledLayers } +) +if (@($EnabledLayers | Where-Object { $_ -cnotin $indexedLayers }).Count) { + throw "Index layer coverage does not cover EnabledLayers. $recovery" +} + +$resolved = [Collections.Generic.List[string]]::new() +$records = [Collections.Generic.List[object]]::new() +$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +$articleTexts = [Collections.Generic.Dictionary[string, string]]::new([StringComparer]::Ordinal) +$sampleContents = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) +foreach ($path in $Paths) { + $kind = if ($Samples) { 'sample' } else { 'article' } + $fullPath = Resolve-KnowledgePath -Root $BCQualityRoot -Path $path -Kind $kind + if ($path.Split('/')[0] -cnotin $EnabledLayers) { + throw "Layer disabled for path: $path" + } + if (-not $seen.Add($path)) { + throw "Duplicate requested path: $path" + } + if ($Samples) { + $articlePath = $path -replace '\.(good|bad)\.[a-z0-9]+$', '.md' + if (-not $byPath.ContainsKey($articlePath)) { + throw "Sample article is absent from the prepared index: $articlePath" + } + $fullArticlePath = Resolve-KnowledgePath -Root $BCQualityRoot -Path $articlePath + if (-not $articleTexts.ContainsKey($articlePath)) { + $articleContent = Read-KnowledgeText -Path $fullArticlePath + if ($articleContent.sha256 -cne $byPath[$articlePath].sourceSha256) { + throw "Selected article hash does not match the prepared index: $articlePath" + } + $articleTexts.Add($articlePath, $articleContent.text) + } + Assert-SampleLink -ArticleText $articleTexts[$articlePath] -SamplePath $fullPath + $sampleContent = Read-KnowledgeText -Path $fullPath + $sampleContents.Add($path, $sampleContent) + $records.Add([ordered]@{ + path = $path + articlePath = $articlePath + articleSha256 = $byPath[$articlePath].sourceSha256 + sampleSha256 = $sampleContent.sha256 + sampleBytes = $sampleContent.bytes + }) + } + else { + if (-not $byPath.ContainsKey($path)) { + throw "Selected article is absent from the prepared index: $path" + } + $records.Add([ordered]@{ + path = $path + expectedSha256 = $byPath[$path].sourceSha256 + }) + } + $resolved.Add($fullPath) +} + +$requestSnapshot = Get-ResultSnapshot -Value ([ordered]@{ + root = $BCQualityRoot + preparedIndexSha256 = $preparedIndex.content.sha256 + kind = if ($Samples) { 'samples' } else { 'articles' } + enabledLayers = @($EnabledLayers) + files = @($records) +}) +if ($Snapshot -and $Snapshot -cne $requestSnapshot) { + throw 'Article snapshot changed or continuation belongs to another exact path batch. Discard partial results and restart.' +} + +$articles = [Collections.Generic.List[object]]::new() +function ConvertTo-BatchJson { + param([int] $ReadCount) + + $remaining = @( + if ($ReadCount -lt $Paths.Count) { + $Paths[$ReadCount..($Paths.Count - 1)] + } + ) + $remainingRecords = @( + if ($ReadCount -lt $records.Count) { + $records[$ReadCount..($records.Count - 1)] + } + ) + $continuation = if ($remaining.Count) { + [ordered]@{ + snapshot = Get-ResultSnapshot -Value ([ordered]@{ + root = $BCQualityRoot + preparedIndexSha256 = $preparedIndex.content.sha256 + kind = if ($Samples) { 'samples' } else { 'articles' } + enabledLayers = @($EnabledLayers) + files = $remainingRecords + }) + } + } + else { + $null + } + return [ordered]@{ + version = 1 + kind = if ($Samples) { 'samples' } else { 'articles' } + snapshot = $requestSnapshot + requestedCount = $Paths.Count + returnedCount = $ReadCount + complete = ($ReadCount -eq $Paths.Count) + articles = @($articles) + remainingPaths = $remaining + continuation = $continuation + } | ConvertTo-Json -Depth 8 -Compress +} + +$json = '' +for ($i = 0; $i -lt [Math]::Min($MaxArticles, $Paths.Count); $i++) { + $content = if ($Samples) { + $sampleContents[$Paths[$i]] + } + else { + Read-KnowledgeText -Path $resolved[$i] + } + if (-not $Samples -and $content.sha256 -cne $records[$i].expectedSha256) { + throw "Selected article hash does not match the prepared index: $($Paths[$i])" + } + $articles.Add([ordered]@{ + path = $Paths[$i] + bytes = $content.bytes + sha256 = $content.sha256 + body = $content.text + }) + $next = ConvertTo-BatchJson -ReadCount ($i + 1) + if ((Get-SerializedByteCount -Json $next) -gt $MaxBytes) { + $articles.RemoveAt($articles.Count - 1) + if ($i -eq 0) { + throw "No complete body plus continuation fits MaxBytes=$MaxBytes at $($Paths[$i]). Use a smaller exact path batch or READ's bounded native-file fallback; never truncate." + } + break + } + $json = $next +} + +$json diff --git a/tools/Knowledge-Retrieval.ps1 b/tools/Knowledge-Retrieval.ps1 new file mode 100644 index 0000000..7007868 --- /dev/null +++ b/tools/Knowledge-Retrieval.ps1 @@ -0,0 +1,298 @@ +# Shared filesystem guards for catalog and exact article retrieval. +Set-StrictMode -Version Latest + +function Resolve-KnowledgeRoot { + param([string] $Root) + + $item = Get-Item -LiteralPath $Root -Force -ErrorAction Stop + if ($item.PSProvider.Name -ne 'FileSystem' -or -not $item.PSIsContainer) { + throw "BCQuality root must be a filesystem directory: $Root" + } + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Linked BCQuality roots are not supported: $Root" + } + return $item.FullName +} + +function Assert-KnowledgePath { + param( + [string] $Path, + [ValidateSet('article', 'sample')] [string] $Kind = 'article' + ) + + if ([string]::IsNullOrWhiteSpace($Path) -or + $Path -cnotmatch '^(microsoft|community|custom)/knowledge/[^/]+/.+' -or + $Path -match '[\\:*?"<>|\x00-\x1f]' -or + @($Path.Split('/') | Where-Object { $_ -in '', '.', '..' -or $_ -match '[. ]$' }).Count) { + throw "Invalid knowledge path: $Path" + } + if (($Kind -eq 'article' -and -not $Path.EndsWith('.md', [StringComparison]::Ordinal)) -or + ($Kind -eq 'sample' -and $Path -cnotmatch '\.(good|bad)\.[a-z0-9]+$')) { + throw "Expected an exact $Kind path: $Path" + } +} + +function Resolve-KnowledgePath { + param( + [string] $Root, + [string] $Path, + [ValidateSet('article', 'sample')] [string] $Kind = 'article' + ) + + Assert-KnowledgePath -Path $Path -Kind $Kind + $current = $Root + foreach ($part in $Path.Split('/')) { + $items = @( + Get-ChildItem -LiteralPath $current -Filter $part -Force -ErrorAction Stop | + Where-Object Name -CEQ $part + ) + if ($items.Count -ne 1) { + throw "Knowledge path does not exist with exact casing: $Path" + } + $item = $items[0] + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Linked knowledge paths are not supported: $Path" + } + $current = $item.FullName + } + if ($item.PSIsContainer) { + throw "Knowledge path is not a file: $Path" + } + return $item.FullName +} + +function Read-KnowledgeText { + param([string] $Path) + + $bytes = [IO.File]::ReadAllBytes($Path) + try { + $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) + } + catch { + throw "Knowledge file is not valid strict UTF-8: $Path" + } + return [pscustomobject]@{ + text = $text + bytes = $bytes.Length + sha256 = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($bytes) + ).ToLowerInvariant() + } +} + +function Get-NormalizedKnowledgeVersions { + param([string[]] $Values) + + foreach ($value in $Values) { + if ($value -match '^"([^"]*)"$' -or $value -match "^'([^']*)'$") { + $Matches[1] + } + else { + $value + } + } +} + +function Get-KnowledgeMetadataProblem { + param([Collections.IDictionary] $Row) + + if ($Row['parsed'] -isnot [bool] -or -not $Row['parsed']) { + return 'unparsed frontmatter' + } + if ($Row['domain'] -isnot [string] -or + $Row['domain'] -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$') { + return 'missing/invalid domain' + } + foreach ($field in @('bc-version', 'technologies', 'countries', 'application-area', 'keywords')) { + if ($Row[$field] -isnot [array] -or -not $Row[$field].Count) { + return "missing/invalid $field" + } + foreach ($value in $Row[$field]) { + if ($value -isnot [string] -or [string]::IsNullOrWhiteSpace($value)) { + return "invalid $field value" + } + } + } + foreach ($field in @('title', 'description')) { + if ($Row[$field] -isnot [string] -or + [string]::IsNullOrWhiteSpace($Row[$field]) -or + $Row[$field] -match '[\r\n]') { + return "missing/invalid $field" + } + } + + $versions = @(Get-NormalizedKnowledgeVersions -Values $Row['bc-version']) + if ($versions -ccontains 'all') { + if ($versions.Count -ne 1) { + return 'mixed bc-version sentinel' + } + } + elseif ($versions.Count -eq 1 -and $versions[0] -match '^(\d+)\.\.(\d+)?$') { + $start = [bigint]::Parse($Matches[1]) + if ($start -le 0 -or ($Matches[2] -and [bigint]::Parse($Matches[2]) -le 0)) { + return 'invalid bc-version range bound' + } + if ($Matches[2] -and $start -gt [bigint]::Parse($Matches[2])) { + return 'descending bc-version range' + } + } + else { + foreach ($version in $versions) { + if ($version -notmatch '^\d+$' -or [bigint]::Parse($version) -le 0) { + return 'invalid bc-version' + } + } + } + if (@($Row.technologies | Where-Object { $_ -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$' }).Count) { + return 'invalid technologies' + } + if ($Row.technologies -ccontains 'all') { + return 'invalid technologies sentinel' + } + if ($Row.countries -ccontains 'w1') { + if ($Row.countries.Count -ne 1) { + return 'mixed countries sentinel' + } + } + elseif (@($Row.countries | Where-Object { $_ -cnotmatch '^[a-z]{2}$' }).Count) { + return 'invalid countries' + } + if (@($Row['application-area'] | Where-Object { $_ -cnotmatch '^(all|[a-z0-9]+(-[a-z0-9]+)*)$' }).Count) { + return 'invalid application-area' + } + if ($Row['application-area'] -ccontains 'all' -and $Row['application-area'].Count -ne 1) { + return 'mixed application-area sentinel' + } + if (@($Row.keywords | Where-Object { $_ -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$' }).Count) { + return 'invalid keywords' + } + return '' +} + +function Get-PreparedManifestSha256 { + param( + [string[]] $Paths, + [Collections.Generic.Dictionary[string, object]] $ByPath + ) + + $hash = [Security.Cryptography.IncrementalHash]::CreateHash( + [Security.Cryptography.HashAlgorithmName]::SHA256 + ) + try { + $hash.AppendData([byte[]][char]'[') + for ($i = 0; $i -lt $Paths.Count; $i++) { + if ($i) { + $hash.AppendData([byte[]][char]',') + } + $row = [ordered]@{ + path = $Paths[$i] + sha256 = $ByPath[$Paths[$i]].sourceSha256 + } + $hash.AppendData([Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject $row -Depth 8 -Compress) + )) + } + $hash.AppendData([byte[]][char]']') + return [Convert]::ToHexString($hash.GetHashAndReset()).ToLowerInvariant() + } + finally { + $hash.Dispose() + } +} + +function Read-PreparedKnowledgeIndex { + param( + [string] $IndexPath, + [string] $Recovery + ) + + if (-not (Test-Path -LiteralPath $IndexPath -PathType Leaf)) { + throw "Knowledge index missing: $IndexPath. $Recovery" + } + $indexItem = Get-Item -LiteralPath $IndexPath -Force -ErrorAction Stop + if ($indexItem.PSProvider.Name -ne 'FileSystem' -or + ($indexItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Knowledge index must be an unlinked filesystem file: $IndexPath. $Recovery" + } + + $content = Read-KnowledgeText -Path $indexItem.FullName + try { + $index = $content.text.TrimStart([char]0xfeff) | + ConvertFrom-Json -AsHashtable -ErrorAction Stop + } + catch { + throw "Malformed knowledge index JSON: $($_.Exception.Message). $Recovery" + } + if ($index -isnot [Collections.IDictionary] -or + $index.version -ne 1 -or + $index.articles -isnot [array] -or + $index.articleCount -ne $index.articles.Count -or + $index.enabledLayers -isnot [array] -or + $index.knowledgeAllow -isnot [array] -or + $index.knowledgeDeny -isnot [array] -or + $index.sourceSnapshot -isnot [string] -or + $index.sourceSnapshot -cnotmatch '^[a-f0-9]{64}$') { + throw "Invalid knowledge index envelope. $Recovery" + } + + $generatedAt = [DateTimeOffset]::MinValue + if ($index.generatedAt -is [DateTime]) { + $generatedAt = [DateTimeOffset]$index.generatedAt + } + elseif (-not [DateTimeOffset]::TryParse( + [string]$index.generatedAt, + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::RoundtripKind, + [ref]$generatedAt + )) { + throw "Invalid knowledge index generatedAt. $Recovery" + } + if ($generatedAt -gt [DateTimeOffset]::UtcNow.AddMinutes(1)) { + throw "Invalid knowledge index generatedAt. $Recovery" + } + + $byPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + foreach ($row in $index.articles) { + if ($row -isnot [Collections.IDictionary] -or $row.path -isnot [string]) { + throw "Index row has no exact path. $Recovery" + } + Assert-KnowledgePath -Path $row.path + if ($row.layer -cne $row.path.Split('/')[0] -or + $row.layer -cnotin @('microsoft', 'community', 'custom')) { + throw "Invalid index layer: $($row.path). $Recovery" + } + if ($row.sourceSha256 -isnot [string] -or + $row.sourceSha256 -cnotmatch '^[a-f0-9]{64}$') { + throw "Invalid source hash in knowledge index: $($row.path). $Recovery" + } + if (-not $byPath.TryAdd($row.path, $row)) { + throw "Duplicate index path: $($row.path). $Recovery" + } + } + + $paths = [string[]]@($byPath.Keys) + [Array]::Sort($paths, [StringComparer]::Ordinal) + if ((Get-PreparedManifestSha256 -Paths $paths -ByPath $byPath) -cne $index.sourceSnapshot) { + throw "Stale or internally inconsistent prepared index snapshot. $Recovery" + } + + return [pscustomobject]@{ + index = $index + content = $content + byPath = $byPath + paths = $paths + } +} + +function Assert-SampleLink { + param( + [string] $ArticleText, + [string] $SamplePath + ) + + $sampleName = [IO.Path]::GetFileName($SamplePath) + $expected = '[`' + $sampleName + '`](' + $sampleName + ')' + if (-not $ArticleText.Contains($expected, [StringComparison]::Ordinal)) { + throw "Sample is not linked by its article using the READ convention: $sampleName" + } +} diff --git a/tools/Search-Knowledge.ps1 b/tools/Search-Knowledge.ps1 new file mode 100644 index 0000000..ade280e --- /dev/null +++ b/tools/Search-Knowledge.ps1 @@ -0,0 +1,244 @@ +<# +.SYNOPSIS + Returns bounded pages of every domain/layer/READ-applicable catalog row. +.DESCRIPTION + Consumes Entry's prepared index read-only. Results are never ranked, sampled, + top-k limited, deduplicated by basename, or narrowed by query text. Omit an + unknown task dimension; an explicit empty array is a known empty set. +#> +#requires -Version 7.2 +[CmdletBinding()] +param( + [ValidateNotNullOrEmpty()] [string] $BCQualityRoot = (Split-Path $PSScriptRoot -Parent), + [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] [string] $Domain, + [ValidateSet('microsoft', 'community', 'custom')] + [AllowEmptyCollection()] [string[]] $EnabledLayers = @('microsoft', 'community', 'custom'), + [ValidateRange(1, 2147483647)] [int] $BCVersion, + [AllowEmptyCollection()] [string[]] $Technologies, + [AllowEmptyCollection()] [string[]] $Countries, + [AllowEmptyCollection()] [string[]] $ApplicationAreas, + [switch] $ExcludeConditional, + [string] $IndexPath, + [ValidateRange(1024, 16000)] [int] $MaxBytes = 16000, + [ValidateRange(0, 2147483647)] [int] $Offset = 0, + [ValidatePattern('^[a-f0-9]{64}$')] [string] $Snapshot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1') +. (Join-Path $PSScriptRoot 'Bounded-Results.ps1') + +$BCQualityRoot = Resolve-KnowledgeRoot $BCQualityRoot +if (-not $IndexPath) { + $IndexPath = Join-Path $BCQualityRoot 'knowledge-index.json' +} +if ($null -eq $EnabledLayers) { + throw 'EnabledLayers must be an array; use an empty array to disable all layers.' +} +if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or + @($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) { + throw 'EnabledLayers must contain unique canonical lowercase layer names.' +} + +$context = [ordered]@{} +foreach ($pair in @( + @('BCVersion', 'bc-version'), + @('Technologies', 'technologies'), + @('Countries', 'countries'), + @('ApplicationAreas', 'application-area') +)) { + if (-not $PSBoundParameters.ContainsKey($pair[0])) { + continue + } + $value = $PSBoundParameters[$pair[0]] + if ($null -eq $value) { + throw "Omit unknown context; do not pass null for $($pair[0])." + } + if ($pair[0] -ne 'BCVersion') { + foreach ($entry in $value) { + if ([string]::IsNullOrWhiteSpace($entry) -or $entry -cne $entry.Trim()) { + throw "Invalid context value for $($pair[0]): '$entry'" + } + } + } + $context[$pair[1]] = $value +} +if ($context.Contains('technologies') -and $context['technologies'] -ccontains 'all') { + throw "Technologies has no 'all' sentinel. Omit unknown context." +} + +$recovery = "Run Entry preparation once before dispatch, or use READ's path-discovery fallback. Do not rebuild in a leaf." +$preparedIndex = Read-PreparedKnowledgeIndex -IndexPath $IndexPath -Recovery $recovery +$index = $preparedIndex.index +$indexContent = $preparedIndex.content +$byPath = $preparedIndex.byPath +$paths = $preparedIndex.paths + +# The v1 generator historically serialized an omitted EnabledLayers parameter as [null]. +$unrestricted = $index.enabledLayers.Count -eq 0 -or + ($index.enabledLayers.Count -eq 1 -and $null -eq $index.enabledLayers[0]) +$indexedLayers = @( + if ($unrestricted) { + 'microsoft', 'community', 'custom' + } + else { + $index.enabledLayers + } +) +if (@($indexedLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or + @($indexedLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count -or + @($EnabledLayers | Where-Object { $_ -cnotin $indexedLayers }).Count) { + throw "Index layer coverage does not cover EnabledLayers. $recovery" +} + +foreach ($path in $paths) { + $row = $byPath[$path] + if ($row.layer -cnotin $indexedLayers) { + throw "Index row layer is outside index coverage: $path. $recovery" + } + $problem = Get-KnowledgeMetadataProblem -Row $row + if ($problem) { + throw "Malformed knowledge index row at ${path}: $problem. $recovery" + } +} + +$defaults = [ordered]@{ + 'bc-version' = @('all') + technologies = @('al') + countries = @('w1') + 'application-area' = @('all') +} +$candidates = [Collections.Generic.List[object]]::new() +$excluded = [Collections.Generic.List[object]]::new() +foreach ($path in $paths) { + $row = $byPath[$path] + if ($row.domain -cne $Domain) { + continue + } + + $unknown = [Collections.Generic.List[string]]::new() + $matchesContext = $true + foreach ($field in $defaults.Keys) { + $values = $row[$field] + if ($field -eq 'bc-version') { + $values = @(Get-NormalizedKnowledgeVersions -Values $values) + } + $sentinel = switch ($field) { + 'bc-version' { 'all' } + 'countries' { 'w1' } + 'application-area' { 'all' } + default { '' } + } + if ($sentinel -and $values -ccontains $sentinel) { + continue + } + if (-not $context.Contains($field)) { + $unknown.Add($field) + continue + } + + $target = $context[$field] + $matched = $false + if ($field -eq 'bc-version') { + # Compare as bigint on both sides: metadata validation accepts bounds + # wider than Int32, and an int left operand would coerce them down. + $targetVersion = [bigint]$target + if ($values.Count -eq 1 -and $values[0] -match '^(\d+)\.\.(\d+)?$') { + $matched = $targetVersion -ge [bigint]::Parse($Matches[1]) -and + (-not $Matches[2] -or $targetVersion -le [bigint]::Parse($Matches[2])) + } + else { + $matched = @($values | Where-Object { [bigint]::Parse($_) -eq $targetVersion }).Count -gt 0 + } + } + else { + $matched = @($values | Where-Object { $target -ccontains $_ }).Count -gt 0 + } + if (-not $matched) { + $matchesContext = $false + break + } + } + if (-not $matchesContext -or ($ExcludeConditional -and $unknown.Count)) { + continue + } + $null = Resolve-KnowledgePath -Root $BCQualityRoot -Path $path + + $candidate = [ordered]@{ + path = $path + layer = $row.layer + keywords = $row.keywords + title = $row.title + description = $row.description + } + foreach ($field in $defaults.Keys) { + if (($row[$field] -join "`0") -cne ($defaults[$field] -join "`0")) { + $candidate[$field] = $row[$field] + } + } + $candidate.applicability = if ($unknown.Count) { 'conditional' } else { 'applicable' } + $candidate.unknownDimensions = @($unknown) + if ($row.layer -cin $EnabledLayers) { + $candidates.Add($candidate) + } + else { + $excluded.Add($candidate) + } +} + +$header = [ordered]@{ + version = 2 + domain = $Domain + context = $context + enabledLayers = @($EnabledLayers) + indexedLayers = @($indexedLayers) + excludeConditional = [bool]$ExcludeConditional + defaults = $defaults + candidateCount = $candidates.Count + excludedByConfigurationCount = $excluded.Count +} + +function Get-CatalogSnapshot { + param( + [string] $PreparedIndexSha256, + [Collections.IDictionary] $Request, + [Collections.IDictionary] $Groups + ) + + $hash = [Security.Cryptography.IncrementalHash]::CreateHash( + [Security.Cryptography.HashAlgorithmName]::SHA256 + ) + try { + foreach ($value in @( + $PreparedIndexSha256, + (ConvertTo-Json -InputObject $Request -Depth 8 -Compress) + )) { + $hash.AppendData([Text.Encoding]::UTF8.GetBytes($value)) + $hash.AppendData([byte[]](10)) + } + foreach ($groupName in $Groups.Keys) { + $hash.AppendData([Text.Encoding]::UTF8.GetBytes("[$groupName]")) + $hash.AppendData([byte[]](10)) + foreach ($row in $Groups[$groupName]) { + $hash.AppendData([Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject $row -Depth 8 -Compress) + )) + $hash.AppendData([byte[]](10)) + } + } + return [Convert]::ToHexString($hash.GetHashAndReset()).ToLowerInvariant() + } + finally { + $hash.Dispose() + } +} + +$groups = [ordered]@{ + candidates = $candidates + excludedByConfiguration = $excluded +} +$header.snapshot = Get-CatalogSnapshot -PreparedIndexSha256 $indexContent.sha256 -Request $header -Groups $groups + +ConvertTo-BoundedPage -Header $header -Groups $groups -Offset $Offset -Snapshot $Snapshot -MaxBytes $MaxBytes diff --git a/tools/Test-KnowledgeRetrieval.ps1 b/tools/Test-KnowledgeRetrieval.ps1 new file mode 100644 index 0000000..ef65812 --- /dev/null +++ b/tools/Test-KnowledgeRetrieval.ps1 @@ -0,0 +1,788 @@ +<# +.SYNOPSIS + Validates lossless bounded catalog and exact-body retrieval. +#> +#requires -Version 7.2 +[CmdletBinding()] +param( + [string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')) +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$Root = (Resolve-Path -LiteralPath $Root).Path + +$generator = Join-Path $Root 'tools/Build-KnowledgeIndex.ps1' +$search = Join-Path $Root 'tools/Search-Knowledge.ps1' +$getArticles = Join-Path $Root 'tools/Get-KnowledgeArticles.ps1' +$utf8 = [Text.UTF8Encoding]::new($false, $true) + +function Assert-True { + param([bool] $Condition, [string] $Message) + if (-not $Condition) { + throw "Assertion failed: $Message" + } +} + +function Assert-Equal { + param($Actual, $Expected, [string] $Message) + if ($Actual -cne $Expected) { + throw "Assertion failed: $Message. Expected '$Expected', got '$Actual'." + } +} + +function Assert-Sequence { + param($Actual, $Expected, [string] $Message) + $actualJson = ConvertTo-Json -InputObject @($Actual) -Compress + $expectedJson = ConvertTo-Json -InputObject @($Expected) -Compress + if ($actualJson -cne $expectedJson) { + throw "Assertion failed: $Message. Expected $expectedJson, got $actualJson." + } +} + +function Assert-Throws { + param([scriptblock] $Action, [string] $Pattern, [string] $Message) + try { + & $Action + } + catch { + if ($_.Exception.Message -notmatch $Pattern) { + throw "Assertion failed: $Message. Wrong error: $($_.Exception.Message)" + } + return + } + throw "Assertion failed: $Message. No error was thrown." +} + +function Get-OutputByteCount { + param([string] $Text) + return [Text.Encoding]::UTF8.GetByteCount($Text) + + [Text.Encoding]::UTF8.GetByteCount([Environment]::NewLine) +} + +function Invoke-CatalogPages { + param( + [hashtable] $Arguments, + [int] $MaxBytes = 4096 + ) + + $allCandidates = [Collections.Generic.List[object]]::new() + $allExcluded = [Collections.Generic.List[object]]::new() + $offset = 0 + $snapshot = '' + $shared = '' + $pageCount = 0 + $lastPage = $null + do { + $pageArguments = @{} + $Arguments + $pageArguments.MaxBytes = $MaxBytes + $pageArguments.Offset = $offset + if ($snapshot) { + $pageArguments.Snapshot = $snapshot + } + $raw = & $search @pageArguments + Assert-True ($raw -is [string]) 'catalog helper emitted exactly one JSON string' + Assert-True ((Get-OutputByteCount -Text $raw) -le $MaxBytes) 'catalog page includes its newline in MaxBytes' + $page = $raw | ConvertFrom-Json + $pageCount++ + Assert-True ($pageCount -le 1000) 'catalog continuation terminates' + Assert-Equal $page.offset $offset 'catalog offset is exact' + Assert-Equal $page.returnedCount (@($page.candidates).Count + @($page.excludedByConfiguration).Count) 'page returnedCount matches rows' + Assert-Equal $page.remainingCount ($page.totalCount - $offset - $page.returnedCount) 'page remainingCount is exact' + + $currentShared = [ordered]@{ + version = $page.version + domain = $page.domain + context = $page.context + enabledLayers = $page.enabledLayers + indexedLayers = $page.indexedLayers + excludeConditional = $page.excludeConditional + defaults = $page.defaults + candidateCount = $page.candidateCount + excludedByConfigurationCount = $page.excludedByConfigurationCount + snapshot = $page.snapshot + totalCount = $page.totalCount + } | ConvertTo-Json -Depth 8 -Compress + if (-not $shared) { + $shared = $currentShared + $snapshot = $page.snapshot + } + else { + Assert-Equal $currentShared $shared 'catalog pages repeat shared context, defaults, totals, and snapshot' + } + + foreach ($row in @($page.candidates)) { + $allCandidates.Add($row) + } + foreach ($row in @($page.excludedByConfiguration)) { + $allExcluded.Add($row) + } + if (-not $page.complete) { + Assert-True ($null -ne $page.continuation) 'incomplete page has continuation' + Assert-Equal $page.continuation.snapshot $snapshot 'continuation is snapshot-bound' + Assert-True ($page.continuation.offset -gt $offset) 'continuation makes progress' + $offset = $page.continuation.offset + } + $lastPage = $page + } while (-not $page.complete) + + Assert-True ($null -eq $lastPage.continuation) 'final page has no continuation' + Assert-Equal $allCandidates.Count $lastPage.candidateCount 'candidate total survives paging' + Assert-Equal $allExcluded.Count $lastPage.excludedByConfigurationCount 'excluded total survives paging' + return [pscustomobject]@{ + candidates = @($allCandidates) + excluded = @($allExcluded) + pages = $pageCount + snapshot = $snapshot + lastPage = $lastPage + } +} + +function Test-BodyRoundTrip { + param( + [string[]] $Paths, + [string] $IndexPath, + [switch] $Samples + ) + + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + for ($start = 0; $start -lt $Paths.Count; $start += 8) { + $end = [Math]::Min($start + 7, $Paths.Count - 1) + $remaining = @($Paths[$start..$end]) + $snapshot = '' + do { + $arguments = @{ + BCQualityRoot = $Root + IndexPath = $IndexPath + Paths = $remaining + MaxArticles = 8 + MaxBytes = 16000 + } + if ($Samples) { + $arguments.Samples = $true + } + if ($snapshot) { + $arguments.Snapshot = $snapshot + } + $raw = & $getArticles @arguments + Assert-True ($raw -is [string]) 'article helper emitted exactly one JSON string' + Assert-True ((Get-OutputByteCount -Text $raw) -le 16000) 'article batch includes its newline in MaxBytes' + $batch = $raw | ConvertFrom-Json + Assert-True ($batch.returnedCount -gt 0) 'article batching makes progress' + Assert-Equal $batch.returnedCount @($batch.articles).Count 'article returnedCount matches rows' + Assert-Equal $batch.complete (@($batch.remainingPaths).Count -eq 0) 'article completion matches remaining paths' + if ($batch.complete) { + Assert-True ($null -eq $batch.continuation) 'complete article batch has no continuation' + } + else { + Assert-True ($batch.continuation.snapshot -match '^[a-f0-9]{64}$') 'article continuation is snapshot-bound' + } + + foreach ($article in @($batch.articles)) { + Assert-True ($seen.Add($article.path)) "body returned once: $($article.path)" + $fullPath = Join-Path $Root ($article.path.Replace('/', [IO.Path]::DirectorySeparatorChar)) + $bytes = [IO.File]::ReadAllBytes($fullPath) + $text = $utf8.GetString($bytes) + $hash = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($bytes) + ).ToLowerInvariant() + Assert-Equal $article.bytes $bytes.Length "byte count round-trips: $($article.path)" + Assert-Equal $article.sha256 $hash "SHA-256 round-trips: $($article.path)" + Assert-Equal $article.body $text "body round-trips: $($article.path)" + } + $remaining = @($batch.remainingPaths) + $snapshot = if ($batch.complete) { '' } else { $batch.continuation.snapshot } + } while ($remaining.Count) + } + Assert-Equal $seen.Count $Paths.Count 'every requested body round-trips without loss' +} + +function New-NeutralArticle { + param( + [string] $FixtureRoot, + [string] $Layer, + [string] $Slug, + [string] $Version = 'all', + [string] $Technology = 'al', + [string] $Country = 'w1', + [string] $Area = 'all', + [string] $Title = 'Neutral retrieval example', + [string] $Description = 'Neutral retrieval metadata for deterministic tests.' + ) + + $directory = Join-Path $FixtureRoot "$Layer\knowledge\neutral" + New-Item -ItemType Directory -Force -Path $directory | Out-Null + $content = @" +--- +bc-version: [$Version] +domain: neutral +keywords: [neutral, retrieval, deterministic] +technologies: [$Technology] +countries: [$Country] +application-area: [$Area] +--- + +# $Title + +## Description + +$Description +"@ + Set-Content -LiteralPath (Join-Path $directory "$Slug.md") -Value $content -Encoding utf8NoBOM +} + +function Test-InvalidSourceIndexing { + param( + [string] $FixtureRoot, + [string] $Field, + [string] $ValidValue, + [string] $InvalidValue + ) + + New-NeutralArticle -FixtureRoot $FixtureRoot -Layer microsoft -Slug valid-source + New-NeutralArticle -FixtureRoot $FixtureRoot -Layer community -Slug invalid-source + $articlePath = Join-Path $FixtureRoot 'community\knowledge\neutral\invalid-source.md' + $text = [IO.File]::ReadAllText($articlePath, $utf8) + $text = $text.Replace("$Field`: $ValidValue", "$Field`: $InvalidValue") + [IO.File]::WriteAllText($articlePath, $text, $utf8) + + $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("$Field-index.json") + $generation = @(& $generator -BCQualityRoot $FixtureRoot -IndexPath $indexPath 3>&1) + $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] }) + Assert-Equal $warnings.Count 1 "scalar $Field source emits one omission warning" + Assert-True ( + $warnings[0].Message -match + "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-source\.md': frontmatter field '$([regex]::Escape($Field))' must use non-empty bracket-array syntax\." + ) "scalar $Field warning identifies the exact path and reason" + $prepared = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + Assert-Equal $prepared.articleCount 1 "scalar $Field source is omitted while its valid sibling is indexed" + Assert-Sequence $prepared.articles.path @('microsoft/knowledge/neutral/valid-source.md') "scalar $Field index contains only the valid sibling" + $catalog = & $search -BCQualityRoot $FixtureRoot -IndexPath $indexPath -Domain neutral | + ConvertFrom-Json + Assert-Sequence $catalog.candidates.path @('microsoft/knowledge/neutral/valid-source.md') "scalar $Field catalog retrieves the valid sibling" + $valid = & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath ` + -Paths 'microsoft/knowledge/neutral/valid-source.md' | + ConvertFrom-Json + Assert-True $valid.complete "scalar $Field valid sibling body retrieves completely" + Assert-Throws { + & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath ` + -Paths 'community/knowledge/neutral/invalid-source.md' + } 'Selected article is absent from the prepared index' "scalar $Field omitted source cannot be retrieved" +} + +function Test-InvalidSemanticIndexing { + param( + [string] $FixtureRoot, + [string] $CaseName, + [string] $Field, + [string] $ValidValue, + [string] $InvalidValue, + [string] $ExpectedReason + ) + + New-NeutralArticle -FixtureRoot $FixtureRoot -Layer microsoft -Slug valid-source + New-NeutralArticle -FixtureRoot $FixtureRoot -Layer community -Slug invalid-source + $articlePath = Join-Path $FixtureRoot 'community\knowledge\neutral\invalid-source.md' + $text = [IO.File]::ReadAllText($articlePath, $utf8) + $text = $text.Replace("$Field`: $ValidValue", "$Field`: $InvalidValue") + [IO.File]::WriteAllText($articlePath, $text, $utf8) + + $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("$CaseName-index.json") + $generation = @(& $generator -BCQualityRoot $FixtureRoot -IndexPath $indexPath 3>&1) + $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] }) + Assert-Equal $warnings.Count 1 "$CaseName emits one omission warning" + Assert-Equal $warnings[0].Message "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-source.md': $ExpectedReason." "$CaseName warning identifies exact path and reason" + + $prepared = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + Assert-Equal $prepared.articleCount 1 "$CaseName omits invalid source and retains valid sibling" + Assert-Sequence $prepared.articles.path @('microsoft/knowledge/neutral/valid-source.md') "$CaseName index contains only valid sibling" + Assert-True ($prepared.sourceSnapshot -match '^[a-f0-9]{64}$') "$CaseName source snapshot remains valid" + $validRow = $prepared.articles[0] + $manifest = @( + [ordered]@{ path = $validRow.path; sha256 = $validRow.sourceSha256 } + ) + $manifestBytes = [Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject $manifest -Depth 8 -Compress) + ) + $expectedSnapshot = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($manifestBytes) + ).ToLowerInvariant() + Assert-Equal $prepared.sourceSnapshot $expectedSnapshot "$CaseName source snapshot covers only retained rows" + + $catalog = & $search -BCQualityRoot $FixtureRoot -IndexPath $indexPath -Domain neutral | + ConvertFrom-Json + Assert-Sequence $catalog.candidates.path @('microsoft/knowledge/neutral/valid-source.md') "$CaseName catalog retains valid sibling" + $valid = & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath ` + -Paths 'microsoft/knowledge/neutral/valid-source.md' | + ConvertFrom-Json + Assert-True $valid.complete "$CaseName valid sibling body retrieves" + Assert-Throws { + & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath ` + -Paths 'community/knowledge/neutral/invalid-source.md' + } 'Selected article is absent from the prepared index' "$CaseName invalid source cannot be retrieved" +} + +function Test-InvalidEnabledLayers { + param( + [string] $FixtureRoot, + [string] $CaseName, + $Layers, + [string] $ExpectedPattern + ) + + $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("layers-$CaseName.json") + $arguments = @{ + BCQualityRoot = $FixtureRoot + IndexPath = $indexPath + EnabledLayers = $Layers + } + Assert-Throws { + & $generator @arguments + } $ExpectedPattern "$CaseName EnabledLayers fails" + Assert-True (-not (Test-Path -LiteralPath $indexPath)) "$CaseName fails before index creation" +} + +$tmp = Join-Path ([IO.Path]::GetTempPath()) ("bcquality_retrieval_" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Force -Path $tmp | Out-Null +try { + $indexPath = Join-Path $tmp 'knowledge-index.json' + & $generator -BCQualityRoot $Root -IndexPath $indexPath | Out-Null + $index = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + $diskArticlePaths = @( + foreach ($layer in 'microsoft', 'community', 'custom') { + $knowledge = Join-Path $Root "$layer\knowledge" + if (Test-Path -LiteralPath $knowledge) { + Get-ChildItem -LiteralPath $knowledge -Recurse -File -Filter '*.md' | + ForEach-Object { + [IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/') + } + } + } + ) | Sort-Object + Assert-Equal $index.articleCount $diskArticlePaths.Count 'index covers every current article' + Assert-True ($index.sourceSnapshot -match '^[a-f0-9]{64}$') 'index carries an exact source snapshot' + + $allCatalogRows = [Collections.Generic.List[object]]::new() + $domains = @($index.articles.domain | Sort-Object -Unique) + foreach ($domain in $domains) { + $catalog = Invoke-CatalogPages -Arguments @{ + BCQualityRoot = $Root + IndexPath = $indexPath + Domain = $domain + } + Assert-Equal $catalog.excluded.Count 0 "all layers enabled for $domain" + foreach ($row in $catalog.candidates) { + $allCatalogRows.Add($row) + } + } + + $expectedRows = @($index.articles | Sort-Object path) + $actualRows = @($allCatalogRows | Sort-Object path) + Assert-Equal $actualRows.Count $expectedRows.Count 'paged union has no top-k or query-based loss' + Assert-Sequence ($actualRows.path) ($expectedRows.path) 'paged union equals all READ-filtered candidates' + Assert-Equal @($actualRows.path | Sort-Object -Unique).Count $actualRows.Count 'catalog does not deduplicate distinct paths' + + $defaults = [ordered]@{ + 'bc-version' = @('all') + technologies = @('al') + countries = @('w1') + 'application-area' = @('all') + } + for ($i = 0; $i -lt $actualRows.Count; $i++) { + $actual = $actualRows[$i] + $expected = $expectedRows[$i] + Assert-Equal $actual.path $expected.path 'catalog preserves exact path' + Assert-Equal $actual.layer $expected.layer 'catalog preserves layer' + Assert-Sequence $actual.keywords $expected.keywords 'catalog preserves full keywords' + Assert-Equal $actual.title $expected.title 'catalog preserves title' + Assert-Equal $actual.description $expected.description 'catalog preserves one-line description' + + $unknown = [Collections.Generic.List[string]]::new() + foreach ($field in $defaults.Keys) { + $expectedValues = @($expected.$field) + $sentinel = switch ($field) { + 'bc-version' { 'all' } + 'countries' { 'w1' } + 'application-area' { 'all' } + default { '' } + } + if (-not $sentinel -or $expectedValues -notcontains $sentinel) { + $unknown.Add($field) + } + $hasField = $actual.PSObject.Properties.Name -ccontains $field + if (($expectedValues -join "`0") -ceq (@($defaults[$field]) -join "`0")) { + Assert-True (-not $hasField) "default field is inherited from page: $field" + } + else { + Assert-True $hasField "non-default field survives paging: $field" + Assert-Sequence $actual.$field $expectedValues "non-default field is exact: $field" + } + } + Assert-Equal $actual.applicability ($(if ($unknown.Count) { 'conditional' } else { 'applicable' })) 'applicability verdict is explicit' + Assert-Sequence $actual.unknownDimensions @($unknown) 'unknown dimensions are explicit' + } + + $performanceFirst = & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -MaxBytes 4096 | + ConvertFrom-Json + Assert-True (-not $performanceFirst.complete) 'large domain produces deterministic continuation' + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -MaxBytes 4096 -Offset $performanceFirst.continuation.offset + } 'Continuation requires Snapshot' 'continuation without snapshot fails' + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -Offset $performanceFirst.totalCount + } 'Invalid Offset' 'offset at total fails' + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -Offset 1 -Snapshot ('0' * 64) + } 'Snapshot changed' 'wrong snapshot fails' + + $changedRawIndex = Join-Path $tmp 'changed-raw-index.json' + $changedRaw = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + $changedRaw.generatedAt = [DateTimeOffset]::UtcNow.ToString('O') + $changedRaw | ConvertTo-Json -Depth 8 -Compress | + Set-Content -LiteralPath $changedRawIndex -Encoding utf8NoBOM -NoNewline + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $changedRawIndex -Domain performance ` + -MaxBytes 4096 -Offset $performanceFirst.continuation.offset ` + -Snapshot $performanceFirst.continuation.snapshot + } 'Snapshot changed' 'continuation is bound to the exact prepared index bytes' + + $malformedIndex = Join-Path $tmp 'malformed.json' + Set-Content -LiteralPath $malformedIndex -Value '{not-json' -Encoding utf8NoBOM + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $malformedIndex -Domain performance + } 'Malformed knowledge index JSON' 'malformed JSON fails' + + $unsafeIndex = Join-Path $tmp 'unsafe.json' + $unsafe = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + $unsafe.articles[0].path = '../outside.md' + $unsafe | ConvertTo-Json -Depth 8 -Compress | + Set-Content -LiteralPath $unsafeIndex -Encoding utf8NoBOM + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $unsafeIndex -Domain performance + } 'Invalid knowledge path' 'unsafe indexed path fails' + + $invalidRowIndex = Join-Path $tmp 'invalid-row.json' + $invalidRow = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + $invalidRow.articles[0].keywords = @() + $invalidRow | ConvertTo-Json -Depth 8 -Compress | + Set-Content -LiteralPath $invalidRowIndex -Encoding utf8NoBOM + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $invalidRowIndex -Domain performance + } 'Malformed knowledge index row' 'malformed index row fails' + + $semanticCorruptIndex = Join-Path $tmp 'semantic-corrupt-row.json' + $semanticCorrupt = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + $semanticCorrupt.articles[0].countries = @('usa') + $semanticCorrupt | ConvertTo-Json -Depth 8 -Compress | + Set-Content -LiteralPath $semanticCorruptIndex -Encoding utf8NoBOM + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $semanticCorruptIndex -Domain performance + } 'Malformed knowledge index row.*invalid countries' 'search rejects semantically invalid external index rows' + + $corruptSemanticCases = @( + @{ name = 'uppercase-all'; field = 'bc-version'; value = @('ALL'); reason = 'invalid bc-version' }, + @{ name = 'uppercase-w1'; field = 'countries'; value = @('W1'); reason = 'invalid countries' }, + @{ name = 'zero-open-range'; field = 'bc-version'; value = @('"0.."'); reason = 'invalid bc-version range bound' }, + @{ name = 'zero-closed-range'; field = 'bc-version'; value = @('"0..0"'); reason = 'invalid bc-version range bound' } + ) + foreach ($case in $corruptSemanticCases) { + $corruptPath = Join-Path $tmp ("corrupt-$($case.name).json") + $corrupt = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + $corrupt.articles[0].PSObject.Properties[$case.field].Value = $case.value + $corrupt | ConvertTo-Json -Depth 8 -Compress | + Set-Content -LiteralPath $corruptPath -Encoding utf8NoBOM + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $corruptPath -Domain performance + } "Malformed knowledge index row.*$([regex]::Escape($case.reason))" "search rejects $($case.name) in an external index" + } + + $invalidUtf8Root = Join-Path $tmp 'invalid-utf8-source' + New-NeutralArticle -FixtureRoot $invalidUtf8Root -Layer microsoft -Slug valid-catalog + New-NeutralArticle -FixtureRoot $invalidUtf8Root -Layer community -Slug invalid-utf8-source + $invalidUtf8Article = Join-Path $invalidUtf8Root 'community\knowledge\neutral\invalid-utf8-source.md' + $validBytes = [IO.File]::ReadAllBytes($invalidUtf8Article) + [IO.File]::WriteAllBytes($invalidUtf8Article, [byte[]]@($validBytes + @(0xc3, 0x28))) + $invalidUtf8Index = Join-Path $tmp 'invalid-utf8-index.json' + $generation = @(& $generator -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index 3>&1) + $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] }) + Assert-Equal $warnings.Count 1 'malformed UTF-8 source emits one omission warning' + Assert-Equal $warnings[0].Message "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-utf8-source.md': invalid UTF-8." 'malformed UTF-8 warning identifies the exact path and reason' + $invalidUtf8Prepared = Get-Content -LiteralPath $invalidUtf8Index -Raw -Encoding utf8 | + ConvertFrom-Json + Assert-Equal $invalidUtf8Prepared.articleCount 1 'malformed UTF-8 source is omitted while its valid sibling is indexed' + Assert-Sequence $invalidUtf8Prepared.articles.path @('microsoft/knowledge/neutral/valid-catalog.md') 'malformed UTF-8 index contains only the valid sibling' + $validCatalog = & $search -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index -Domain neutral | + ConvertFrom-Json + Assert-Sequence $validCatalog.candidates.path @('microsoft/knowledge/neutral/valid-catalog.md') 'catalog retrieves the valid sibling after malformed UTF-8 omission' + $validBody = & $getArticles -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index ` + -Paths 'microsoft/knowledge/neutral/valid-catalog.md' | + ConvertFrom-Json + Assert-True $validBody.complete 'valid sibling body retrieves after malformed UTF-8 omission' + Assert-Throws { + & $getArticles -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index ` + -Paths 'community/knowledge/neutral/invalid-utf8-source.md' + } 'Selected article is absent from the prepared index' 'omitted malformed UTF-8 source cannot be retrieved' + + $scalarCases = @( + @{ field = 'bc-version'; valid = '[all]'; invalid = 'all' }, + @{ field = 'keywords'; valid = '[neutral, retrieval, deterministic]'; invalid = 'neutral' }, + @{ field = 'technologies'; valid = '[al]'; invalid = 'al' }, + @{ field = 'countries'; valid = '[w1]'; invalid = 'w1' }, + @{ field = 'application-area'; valid = '[all]'; invalid = 'all' } + ) + foreach ($case in $scalarCases) { + Test-InvalidSourceIndexing -FixtureRoot (Join-Path $tmp "scalar-$($case.field)") ` + -Field $case.field -ValidValue $case.valid -InvalidValue $case.invalid + } + + $semanticCases = @( + @{ name = 'mixed-version-sentinel'; field = 'bc-version'; valid = '[all]'; invalid = '[all, 27]'; reason = 'mixed bc-version sentinel' }, + @{ name = 'invalid-country'; field = 'countries'; valid = '[w1]'; invalid = '[usa]'; reason = 'invalid countries' }, + @{ name = 'descending-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["28..27"]'; reason = 'descending bc-version range' }, + @{ name = 'malformed-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '[twenty-seven]'; reason = 'invalid bc-version' }, + @{ name = 'malformed-keyword'; field = 'keywords'; valid = '[neutral, retrieval, deterministic]'; invalid = '[neutral, Bad_Token, deterministic]'; reason = 'invalid keywords' }, + @{ name = 'malformed-technology'; field = 'technologies'; valid = '[al]'; invalid = '[AL]'; reason = 'invalid technologies' }, + @{ name = 'malformed-application-area'; field = 'application-area'; valid = '[all]'; invalid = '[finance_]'; reason = 'invalid application-area' }, + @{ name = 'uppercase-version-sentinel'; field = 'bc-version'; valid = '[all]'; invalid = '[ALL]'; reason = 'invalid bc-version' }, + @{ name = 'uppercase-country-sentinel'; field = 'countries'; valid = '[w1]'; invalid = '[W1]'; reason = 'invalid countries' }, + @{ name = 'zero-open-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["0.."]'; reason = 'invalid bc-version range bound' }, + @{ name = 'zero-closed-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["0..0"]'; reason = 'invalid bc-version range bound' } + ) + foreach ($case in $semanticCases) { + Test-InvalidSemanticIndexing -FixtureRoot (Join-Path $tmp "semantic-$($case.name)") ` + -CaseName $case.name -Field $case.field -ValidValue $case.valid ` + -InvalidValue $case.invalid -ExpectedReason $case.reason + } + + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain ('x' * 2000) -MaxBytes 1024 + } 'Page envelope exceeds' 'oversized page envelope fails' + + $articlePaths = @($index.articles.path | Sort-Object) + Assert-Sequence $articlePaths $diskArticlePaths 'exact article path union matches disk' + Assert-Throws { + & $getArticles -BCQualityRoot $Root -IndexPath $indexPath -Paths @($articlePaths[0..8]) + } 'exceeds MaxArticles=8' 'exact retrieval rejects path batches larger than eight' + $samplePaths = @( + foreach ($layer in 'microsoft', 'community', 'custom') { + $knowledge = Join-Path $Root "$layer\knowledge" + if (Test-Path -LiteralPath $knowledge) { + Get-ChildItem -LiteralPath $knowledge -Recurse -File | + Where-Object Name -Match '\.(good|bad)\.[a-z0-9]+$' | + ForEach-Object { + [IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/') + } + } + } + ) | Sort-Object + Test-BodyRoundTrip -Paths $articlePaths -IndexPath $indexPath + Test-BodyRoundTrip -Paths $samplePaths -IndexPath $indexPath -Samples + + $fixtureRoot = Join-Path $tmp 'neutral' + New-NeutralArticle -FixtureRoot $fixtureRoot -Layer microsoft -Slug default + New-NeutralArticle -FixtureRoot $fixtureRoot -Layer community -Slug versioned -Version '"27.."' -Technology javascript -Country dk -Area finance -Title 'Versioned neutral example' + New-NeutralArticle -FixtureRoot $fixtureRoot -Layer custom -Slug localized -Version 28 -Technology al -Country de -Area service -Title 'Localized neutral example' + $fixtureIndex = Join-Path $tmp 'neutral-index.json' + & $generator -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex | Out-Null + + foreach ($case in @( + @{ name = 'uppercase'; layers = @('Microsoft'); pattern = 'unique canonical lowercase layer names' }, + @{ name = 'duplicate'; layers = @('microsoft', 'microsoft'); pattern = 'unique canonical lowercase layer names' }, + @{ name = 'unknown'; layers = @('partner'); pattern = 'unique canonical lowercase layer names' }, + @{ name = 'null'; layers = $null; pattern = 'must be an array' } + )) { + Test-InvalidEnabledLayers -FixtureRoot $fixtureRoot -CaseName $case.name ` + -Layers $case.layers -ExpectedPattern $case.pattern + } + $subsetIndex = Join-Path $tmp 'community-only-index.json' + & $generator -BCQualityRoot $fixtureRoot -IndexPath $subsetIndex ` + -EnabledLayers @('community') | Out-Null + $subset = & $search -BCQualityRoot $fixtureRoot -IndexPath $subsetIndex ` + -Domain neutral -EnabledLayers @('community') | + ConvertFrom-Json + Assert-Equal $subset.candidateCount 1 'valid EnabledLayers subset builds and is consumable' + Assert-Sequence $subset.candidates.path @('community/knowledge/neutral/versioned.md') 'valid subset contains only its exact layer' + + $applicable = Invoke-CatalogPages -Arguments @{ + BCQualityRoot = $fixtureRoot + IndexPath = $fixtureIndex + Domain = 'neutral' + BCVersion = 28 + Technologies = @('al', 'javascript') + Countries = @('dk', 'de') + ApplicationAreas = @('finance', 'service') + } -MaxBytes 16000 + Assert-Equal $applicable.candidates.Count 3 'neutral layer/version rows all survive matching context' + Assert-True (@($applicable.candidates | Where-Object applicability -CEQ applicable).Count -eq 3) 'matching rows are applicable' + $versioned = $applicable.candidates | Where-Object path -CEQ 'community/knowledge/neutral/versioned.md' + Assert-Equal $versioned.layer community 'non-default layer survives' + Assert-Sequence $versioned.'bc-version' @('"27.."') 'original version metadata survives' + Assert-Equal $versioned.applicability applicable 'lowercase sentinels and positive open range remain applicable' + Assert-Sequence $versioned.technologies @('javascript') 'non-default technology survives' + Assert-Sequence $versioned.countries @('dk') 'non-default country survives' + Assert-Sequence $versioned.'application-area' @('finance') 'non-default application area survives' + + # Metadata validation accepts range bounds wider than Int32, so version + # matching must compare as bigint rather than coercing the bound down. + $wideRoot = Join-Path $tmp 'wide-version' + New-NeutralArticle -FixtureRoot $wideRoot -Layer microsoft -Slug wide-closed -Version '"1..99999999999"' + New-NeutralArticle -FixtureRoot $wideRoot -Layer microsoft -Slug wide-open -Version '"99999999999.."' + $wideIndex = Join-Path $tmp 'wide-version-index.json' + & $generator -BCQualityRoot $wideRoot -IndexPath $wideIndex | Out-Null + $wide = Invoke-CatalogPages -Arguments @{ + BCQualityRoot = $wideRoot + IndexPath = $wideIndex + Domain = 'neutral' + BCVersion = 28 + } -MaxBytes 16000 + Assert-Sequence $wide.candidates.path @('microsoft/knowledge/neutral/wide-closed.md') 'bc-version bounds beyond Int32 compare without overflow' + + $conditional = Invoke-CatalogPages -Arguments @{ + BCQualityRoot = $fixtureRoot + IndexPath = $fixtureIndex + Domain = 'neutral' + BCVersion = 28 + Technologies = @('al', 'javascript') + } -MaxBytes 16000 + $conditionalVersioned = $conditional.candidates | + Where-Object path -CEQ 'community/knowledge/neutral/versioned.md' + Assert-Equal $conditionalVersioned.applicability conditional 'unknown context produces conditional verdict' + Assert-Sequence $conditionalVersioned.unknownDimensions @('countries', 'application-area') 'unknown dimensions survive' + + $layerFiltered = Invoke-CatalogPages -Arguments @{ + BCQualityRoot = $fixtureRoot + IndexPath = $fixtureIndex + Domain = 'neutral' + EnabledLayers = @('microsoft') + } -MaxBytes 16000 + Assert-Equal $layerFiltered.candidates.Count 1 'enabled layer remains a candidate' + Assert-Equal $layerFiltered.excluded.Count 2 'disabled layers remain explicit' + Assert-Sequence ($layerFiltered.excluded.layer | Sort-Object) @('community', 'custom') 'excluded rows preserve layer' + + $oldSnapshot = $conditional.snapshot + Add-Content -LiteralPath (Join-Path $fixtureRoot 'community\knowledge\neutral\versioned.md') -Value ' ' -Encoding utf8NoBOM + $preparedCatalog = & $search -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex -Domain neutral | + ConvertFrom-Json + Assert-Equal $preparedCatalog.candidateCount 3 'catalog uses the prepared index without rehashing article bodies' + Assert-Throws { + & $getArticles -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex ` + -Paths 'community/knowledge/neutral/versioned.md' + } 'Selected article hash does not match the prepared index' 'exact retrieval detects selected article changes' + & $generator -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex | Out-Null + Assert-Throws { + & $search -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex -Domain neutral -Offset 1 -Snapshot $oldSnapshot + } 'Snapshot changed' 'continuation cannot cross rebuilt snapshots' + + $largeRoot = Join-Path $tmp 'large-catalog' + New-NeutralArticle -FixtureRoot $largeRoot -Layer microsoft -Slug huge-title -Title ('T' * 3000) + $largeIndex = Join-Path $tmp 'large-index.json' + & $generator -BCQualityRoot $largeRoot -IndexPath $largeIndex | Out-Null + Assert-Throws { + & $search -BCQualityRoot $largeRoot -IndexPath $largeIndex -Domain neutral -MaxBytes 1024 + } 'One complete candidates row|Page envelope exceeds' 'oversized catalog row fails without clipping' + + # The shared pager reports the oversized row's identity for any row shape; + # a row without a path must still reach its explicit offset-based failure. + . (Join-Path $Root 'tools/Bounded-Results.ps1') + $pagerHeader = [ordered]@{ version = 2; snapshot = ('0' * 64) } + foreach ($shape in @( + @{ name = 'dictionary'; row = [ordered]@{ blob = ('x' * 3000) } }, + @{ name = 'object'; row = [pscustomobject]@{ blob = ('x' * 3000) } } + )) { + Assert-Throws { + ConvertTo-BoundedPage -Header $pagerHeader ` + -Groups ([ordered]@{ rows = @($shape.row) }) -MaxBytes 1024 + } 'One complete rows row plus envelope exceeds MaxBytes=1024 at Offset=0' "oversized pathless $($shape.name) row fails with its offset identity" + } + + $bodyRoot = Join-Path $tmp 'body-failures' + New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug huge-body -Description ('x' * 3000) + New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug broken-link + New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug continuation-one -Description ('a' * 300) + New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug continuation-two -Description ('b' * 300) + New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug invalid-utf8 + New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug sample-one + New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug sample-two + Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-one.md') ` + -Value '[`sample-one.good.al`](sample-one.good.al)' -Encoding utf8NoBOM + Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.md') ` + -Value '[`sample-two.good.al`](sample-two.good.al)' -Encoding utf8NoBOM + Set-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-one.good.al') ` + -Value ('a' * 900) -Encoding utf8NoBOM + Set-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.good.al') ` + -Value ('b' * 900) -Encoding utf8NoBOM + $bodyIndex = Join-Path $tmp 'body-index.json' + & $generator -BCQualityRoot $bodyRoot -IndexPath $bodyIndex | Out-Null + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths 'microsoft/knowledge/neutral/huge-body.md' -MaxBytes 1024 + } 'No complete body plus continuation fits' 'oversized body fails without truncation' + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex -Paths '../outside.md' + } 'Invalid knowledge path' 'unsafe requested path fails' + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths 'microsoft/knowledge/neutral/huge-body.md' -EnabledLayers community + } 'Layer disabled' 'disabled article layer fails' + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex -Paths @( + 'microsoft/knowledge/neutral/huge-body.md', + 'microsoft/knowledge/neutral/huge-body.md' + ) + } 'Duplicate requested path' 'duplicate exact paths fail' + + $brokenSample = Join-Path $bodyRoot 'microsoft\knowledge\neutral\broken-link.good.al' + Set-Content -LiteralPath $brokenSample -Value 'codeunit 1 Neutral { }' -Encoding utf8NoBOM + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths 'microsoft/knowledge/neutral/broken-link.good.al' -Samples + } 'Sample is not linked' 'unlinked sample fails' + + $sampleContinuationPaths = @( + 'microsoft/knowledge/neutral/sample-one.good.al', + 'microsoft/knowledge/neutral/sample-two.good.al' + ) + $firstSamplePage = & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths $sampleContinuationPaths -Samples -MaxBytes 1600 | + ConvertFrom-Json + Assert-True (-not $firstSamplePage.complete) 'bounded sample batch produces continuation' + Assert-Sequence $firstSamplePage.remainingPaths @('microsoft/knowledge/neutral/sample-two.good.al') 'sample continuation preserves pending path' + Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.good.al') ` + -Value 'changed' -Encoding utf8NoBOM + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths @($firstSamplePage.remainingPaths) -Samples ` + -Snapshot $firstSamplePage.continuation.snapshot + } 'Article snapshot changed' 'sample continuation rejects a changed pending sample' + + $continuationPaths = @( + 'microsoft/knowledge/neutral/continuation-one.md', + 'microsoft/knowledge/neutral/continuation-two.md' + ) + $firstBodyPage = & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths $continuationPaths -MaxBytes 1300 | + ConvertFrom-Json + Assert-True (-not $firstBodyPage.complete) 'bounded article batch produces continuation' + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths @($firstBodyPage.remainingPaths) -Snapshot ('0' * 64) + } 'Article snapshot changed' 'wrong article continuation snapshot fails' + Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\continuation-two.md') -Value 'changed' -Encoding utf8NoBOM + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths @($firstBodyPage.remainingPaths) -Snapshot $firstBodyPage.continuation.snapshot + } 'Selected article hash does not match the prepared index' 'article continuation rejects a changed remaining body' + + $invalidUtf8 = Join-Path $bodyRoot 'microsoft\knowledge\neutral\invalid-utf8.md' + $indexedBytes = [IO.File]::ReadAllBytes($invalidUtf8) + [IO.File]::WriteAllBytes($invalidUtf8, [byte[]]@($indexedBytes + @(0xc3, 0x28))) + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths 'microsoft/knowledge/neutral/invalid-utf8.md' + } 'Knowledge file is not valid strict UTF-8' 'invalid UTF-8 fails' + + Write-Host "Knowledge retrieval check PASSED: $($articlePaths.Count) articles and $($samplePaths.Count) samples round-tripped; catalog union was lossless and bounded." -ForegroundColor Green +} +finally { + Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue +} From 35d0966a8d45e8d4a7c2b0238afbffe338a31d2b Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Fri, 11 Sep 2026 15:24:20 +0200 Subject: [PATCH 71/86] Normalize recoverable leaf finding ranges (#180) * Normalize recoverable leaf ranges Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Run contract checks with review fixtures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/contributing.md | 7 +- docs/standalone-runner.md | 20 ++- microsoft/skills/review/al-code-review.md | 13 +- skills/do.md | 57 ++++++-- tools/Test-ReviewContract.ps1 | 171 ++++++++++++++++++++++ tools/Test-ReviewFixtures.ps1 | 1 + 6 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 tools/Test-ReviewContract.ps1 diff --git a/docs/contributing.md b/docs/contributing.md index 27da177..51f6d0f 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -161,12 +161,15 @@ If PyYAML is not installed in your development environment, install it with ```powershell python .github\scripts\validate_frontmatter.py --root . pwsh .\tools\Test-ReviewFixtures.ps1 -Root . +pwsh .\tools\Test-ReviewContract.ps1 -Root . ``` The first command checks schema, sections, naming, sample references, and skill registration. The second checks that every review leaf has a valid -positive/clean sample pair. Neither proves a model will find every defect. -See [evaluation](../evaluation/README.md) for optional model-based scoring. +positive/clean sample pair. The third checks the cross-surface findings-report +contract and its bounded range-normalization cases. None proves a model will +find every defect. See [evaluation](../evaluation/README.md) for optional +model-based scoring. In the PR description, explain the mistake being prevented, supporting evidence, applicable BC versions, and why the chosen domain owns it. For a diff --git a/docs/standalone-runner.md b/docs/standalone-runner.md index 61ecb55..5829e4e 100644 --- a/docs/standalone-runner.md +++ b/docs/standalone-runner.md @@ -52,10 +52,17 @@ only result. 4. When an action skill declares `sub-skills`, execute every relevant leaf as a discrete invocation. Leaves are independent and may be scheduled serially or concurrently. -5. Collect each complete findings-report into `sub-results` in the declared +5. Capture the exact Task return as the immutable raw audit payload and primary + transport. Preserve it unchanged in private artifacts or host logs. Before + the full DO acceptance gate, create a normalized candidate only for DO's + bounded optional-range case, record that normalization separately in private + telemetry, and accept the candidate only if the entire copy passes the + unchanged strict gate. The accepted report contains no undeclared telemetry + fields. +6. Collect each accepted findings-report into `sub-results` in the declared `sub-skills` order, not completion order. Run the super-skill self-review only after all leaves have finished. -6. Apply the DO composition, failure, deduplication, reference-integrity, and +7. Apply the DO composition, failure, deduplication, reference-integrity, and outcome rules. Return strict JSON before rendering it for people or another system. @@ -86,6 +93,15 @@ A compatible runner: - invokes every worklisted leaf exactly once unless a documented retry replaces a failed attempt; - keeps leaf contexts isolated and passes only the inputs they declare; +- preserves each raw Task return unchanged for audit and distinguishes it from + any normalized accepted copy; +- removes only an optional range whose positive integer bounds contain the + primary line but start before it, and only when the complete report has no + other defect and the finding has no `suggested-code`; +- records normalization only in private runner telemetry and never adds fields + to the findings-report; +- rejects reversed, invalid, or out-of-bounds ranges, range mismatches attached + to `suggested-code`, and every repair outside DO's bounded exception; - preserves every leaf report, including failed reports, in `sub-results`; - excludes unreliable findings from failed leaves and returns `partial` when only part of the review is reliable; diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 6a577ba..9239220 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -67,7 +67,7 @@ The Action step consists of **discrete leaf invocations**, not one combined gene - **Isolate leaf invocations when the host supports it.** Each sub-skill SHOULD run in a fresh model call or child context containing only its assigned source paths, READ/DO contracts, the leaf instructions, the complete bounded domain catalog per READ, and articles that leaf worklists. Preserve each catalog row's exact `path`; the leaf must copy references from that catalog. - **Keep run artifacts private.** Before dispatch, allocate a new GUID-named directory under the current session's artifact directory and a distinct scratch/report child directory for every leaf. Pass a leaf only its own assigned source paths and child directory, never the run root or sibling paths. A leaf MUST NOT discover, enumerate, read, modify, or delete sibling artifacts. Do not reuse a prior run directory, and do not clean up any run artifact until every leaf has finished and consolidation is complete. -- **Use the exact Task return as the report.** Capture each leaf's exact return as the primary transport and apply DO's consumer acceptance gate before rollup. Worker-side persistence of the same report in its private directory is optional and redundant; a missing report file does not invalidate an otherwise valid exact return. +- **Keep raw Task transport distinct from the accepted copy.** Capture the exact Task return as the immutable raw audit payload and primary transport. Preserve it unchanged in the leaf's private artifacts or host log. Then apply DO's bounded pre-gate range normalization, when eligible, and its full consumer acceptance gate. The report accepted for rollup is the exact return when no normalization occurred, or the normalized candidate copy when DO permits it; worker-side persistence of another report file is optional and redundant. - **Treat automatic output spills as host-owned.** If the host reports that a Task return was automatically spilled, the coordinator MAY read that file read-only only at the exact path returned by the tool. Never modify, delete, enumerate around, or reuse an automatic spill path. Never bypass a content-exclusion or access denial. - Treat each sub-skill in the worklist as its own pass: read the sub-skill's instructions, apply its Source → Relevance → Worklist → Action steps to the orchestrator-supplied inputs, and produce that sub-skill's complete findings-report independently. - Do not collapse multiple sub-skills into one shared reasoning step. Each sub-skill has a distinct knowledge subset and a distinct evaluation procedure; sharing one rolled-up scan dilutes per-skill attention and causes leaves to silently underreport (this has been observed in production: leaf skills returned empty `findings[]` while their standalone runs against the same diff produced multiple matches). @@ -80,7 +80,7 @@ The Action step consists of **discrete leaf invocations**, not one combined gene 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`. -2. Capture the exact Task return and validate it against DO's consumer acceptance gate before accepting it. Preserve an invalid raw return unchanged in the leaf's private artifacts or host log; do not reconstruct or repair it. Record a separate failed validation result with no findings for rollup. +2. Capture the exact Task return as the immutable raw audit payload and primary transport. Preserve it unchanged in the leaf's private artifacts or host log before deriving a candidate. Apply only DO's bounded pre-gate normalization: when the complete raw report has no other defect, a finding has positive-integer `line`, `start-line`, and `end-line`, `start-line <= line <= end-line`, `start-line != line`, and no `suggested-code` field, copy the complete report and remove only that finding's optional `location.range`. Record the normalization separately in private run telemetry or artifacts, never in the findings-report. Validate the entire candidate through DO's existing strict acceptance gate. Accept the exact return when unchanged or the normalized candidate when it passes; otherwise record a separate failed validation result with no findings for rollup. Do not reconstruct JSON, infer fields, alter paths or references, clamp lines, normalize reversed or out-of-bounds ranges, remove a range associated with `suggested-code`, or salvage individual findings. 3. Append the accepted findings-report, or the separate failed validation result, to `sub-results`. If its `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, compare each entry from the sub-skill's `findings[]` with findings already rolled up. Two findings are duplicates when they point to the same file and overlapping line/range and prescribe materially the same correction, even when their knowledge-file IDs differ. Merge duplicates instead of appending both: keep the more specific domain owner, preserve that finding's optional `domain` field verbatim (including its absence), use its reference as `references[0]` and therefore as `id`, append the other references as supporting references, keep the highest severity and confidence justified by either report, and preserve one self-contained message. Article and leaf ownership notes decide specificity; do not choose by execution order. 5. Append each non-duplicate finding, setting `from-sub-skill` to the sub-skill's `skill.id` and preserving its optional `domain` field verbatim, including its absence. 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. @@ -126,9 +126,12 @@ Calculate `summary.counts` from the final top-level `findings[]`, after failed s Derive `outcome` using the DO rollup rules. `outcome-reason` is populated for `partial` and `failed` and SHOULD summarize per-sub-skill state, for example: *"al-security-review failed (tool timeout); al-performance-review completed."* Before emitting the rollup, apply DO's consumer acceptance gate to every nested -and top-level finding. Treat an invalid sub-result as failed and exclude all of -its findings from the top-level rollup. Preserve its exact raw payload -separately; never reconstruct it into a success-shaped report. +and top-level finding. A leaf's nested report is its accepted exact return or +its accepted normalized candidate copy; its exact Task return remains the +separate immutable raw audit payload. Treat an invalid sub-result as failed and +exclude all of its findings from the top-level rollup. Never reconstruct it +into a success-shaped report or perform normalization beyond DO's bounded +exception. ## Output diff --git a/skills/do.md b/skills/do.md index f86509b..9abdf58 100644 --- a/skills/do.md +++ b/skills/do.md @@ -161,13 +161,49 @@ AL source is the common failure case. Quoted identifiers (for example `Rec."No." ### Consumer acceptance gate -The exact action-skill return is the primary report transport. Before accepting -it as a findings-report, a coordinator or host MUST validate it -deterministically: +Capture the exact Task return as the immutable raw audit payload and primary +transport. Preserve it unchanged in private run artifacts or host logs before +creating any derived value. The accepted findings-report is either that exact +return or the bounded normalized candidate described below; the raw audit +payload never changes. -1. Parse the exact return as strict JSON and validate every required field, - enum, type, conditional requirement, summary count, coverage value, and - leaf/super-skill constraint against this output contract. +Before the full acceptance gate, a coordinator MAY create a normalized +candidate copy only through this deterministic procedure: + +1. Parse the exact return as strict JSON and provisionally check the complete + report without mutating it. Every acceptance rule below MUST already pass + except for one or more findings whose optional `location.range` has + `start-line != line`. +2. Each such finding is eligible only when `location.line`, + `location.range.start-line`, and `location.range.end-line` are positive + integers, `start-line <= line <= end-line`, and the finding does not contain + the `suggested-code` field. Field presence disqualifies normalization even + if its value is empty because suggested code may be bound to the reported + range. +3. Deep-copy the complete parsed report. In the candidate copy, remove only + `location.range` from every eligible finding. Retain `location.line` and + every other value unchanged. Do not add normalization metadata to the + findings-report. +4. Record each removed range separately in private run telemetry or artifacts, + associated with the immutable raw audit payload. This record is + runner-owned and is not part of the declared report schema. +5. Validate the entire normalized candidate with the existing full consumer + acceptance gate below. Only a candidate that passes every rule becomes the + accepted copy used for rollup. If any other validation defect exists, or + full validation fails, discard the candidate, preserve the raw payload, and + fail the complete leaf as before. + +This exception does not infer missing fields, alter references or paths, clamp +line numbers, repair JSON, normalize a reversed or out-of-bounds range, remove +a range from a finding containing `suggested-code`, or salvage arbitrary +individual findings. + +Before accepting either the exact return or an eligible normalized candidate +as a findings-report, a coordinator or host MUST validate it deterministically: + +1. Validate every required field, enum, type, conditional requirement, summary + count, coverage value, and leaf/super-skill constraint against this output + contract. 2. For every knowledge-backed finding, verify each `references[].path` is an exact repo-relative knowledge path that exists in the live BCQuality snapshot, and verify `findings[].id` exactly equals @@ -183,11 +219,10 @@ deterministically: Validation failure invalidates the complete return; consumers MUST NOT salvage individual findings, infer missing fields, reconstruct JSON, clamp ranges, rewrite paths, or otherwise silently repair model output. Preserve the invalid -raw payload unchanged in private run artifacts or host logs. Record a separate -failed validation result for that leaf with no findings, and derive the -super-skill outcome as `partial` or `failed` using the normal rollup rules. -Worker-side report-file persistence is optional and never replaces validation -of the exact return. +raw payload unchanged. Record a separate failed validation result for that leaf +with no findings, and derive the super-skill outcome as `partial` or `failed` +using the normal rollup rules. Worker-side report-file persistence is optional +and never replaces validation of the accepted exact or normalized copy. ### Field semantics diff --git a/tools/Test-ReviewContract.ps1 b/tools/Test-ReviewContract.ps1 new file mode 100644 index 0000000..1d40058 --- /dev/null +++ b/tools/Test-ReviewContract.ps1 @@ -0,0 +1,171 @@ +<# +.SYNOPSIS + Validates the bounded leaf-range normalization contract. + +.DESCRIPTION + BCQuality has no executable findings-report consumer. These assertions keep + the normative DO contract, AL coordinator, and standalone runner aligned + while exercising the exact normalization predicate against representative + safe and ambiguous inputs. +#> +[CmdletBinding()] +param( + [string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')) +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$Root = (Resolve-Path -LiteralPath $Root).Path + +function Assert-True { + param( + [bool] $Condition, + [string] $Message + ) + + if (-not $Condition) { + throw "Assertion failed: $Message" + } +} + +function Assert-Contains { + param( + [string] $Text, + [string] $Expected, + [string] $Message + ) + + Assert-True $Text.Contains($Expected) $Message +} + +function Test-PositiveInteger { + param([object] $Value) + + if (($null -eq $Value) -or ($Value -is [bool]) -or ($Value -isnot [ValueType])) { + return $false + } + + $number = [double]$Value + return [double]::IsFinite($number) -and ($number -gt 0) -and ([math]::Truncate($number) -eq $number) +} + +function Test-RangeNormalizationEligibility { + param([pscustomobject] $Finding) + + if ($Finding.PSObject.Properties.Name -contains 'suggested-code') { + return $false + } + if (-not ($Finding.PSObject.Properties.Name -contains 'location')) { + return $false + } + if (-not ($Finding.location.PSObject.Properties.Name -contains 'line')) { + return $false + } + if (-not ($Finding.location.PSObject.Properties.Name -contains 'range')) { + return $false + } + + $range = $Finding.location.range + if (-not ($range.PSObject.Properties.Name -contains 'start-line') -or + -not ($range.PSObject.Properties.Name -contains 'end-line')) { + return $false + } + + $line = $Finding.location.line + $startLine = $range.'start-line' + $endLine = $range.'end-line' + if (-not (Test-PositiveInteger $line) -or + -not (Test-PositiveInteger $startLine) -or + -not (Test-PositiveInteger $endLine)) { + return $false + } + + return ($startLine -le $line) -and ($line -le $endLine) -and ($startLine -ne $line) +} + +$transportSentence = 'Capture the exact Task return as the immutable raw audit payload and primary transport.' +$doContract = Get-Content -LiteralPath (Join-Path $Root 'skills/do.md') -Raw +$coordinatorContract = Get-Content -LiteralPath (Join-Path $Root 'microsoft/skills/review/al-code-review.md') -Raw +$runnerContract = Get-Content -LiteralPath (Join-Path $Root 'docs/standalone-runner.md') -Raw + +foreach ($surface in @( + [pscustomobject]@{ Name = 'DO'; Text = ($doContract -replace '\s+', ' ') } + [pscustomobject]@{ Name = 'AL coordinator'; Text = ($coordinatorContract -replace '\s+', ' ') } + [pscustomobject]@{ Name = 'standalone runner'; Text = ($runnerContract -replace '\s+', ' ') } +)) { + Assert-Contains $surface.Text $transportSentence "$($surface.Name) preserves exact Task transport wording" +} + +$normalizedDoContract = $doContract -replace '\s+', ' ' +foreach ($expected in @( + 'positive integers', + 'start-line <= line <= end-line', + 'does not contain the `suggested-code` field', + 'remove only', + 'private run telemetry or artifacts', + 'Validate the entire normalized candidate', + 'If any other validation defect exists', + 'salvage arbitrary individual findings' +)) { + Assert-Contains $normalizedDoContract $expected "DO documents '$expected'" +} + +$cases = @( + [pscustomobject]@{ + Name = 'contained mismatched range without suggested code' + Expected = $true + Finding = '{"message":"keep me","location":{"file":"src/codeunit.al","line":37,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json + } + [pscustomobject]@{ + Name = 'aligned range' + Expected = $false + Finding = '{"location":{"line":37,"range":{"start-line":37,"end-line":38}}}' | ConvertFrom-Json + } + [pscustomobject]@{ + Name = 'suggested code present' + Expected = $false + Finding = '{"location":{"line":37,"range":{"start-line":36,"end-line":38}},"suggested-code":""}' | ConvertFrom-Json + } + [pscustomobject]@{ + Name = 'line outside range' + Expected = $false + Finding = '{"location":{"line":39,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json + } + [pscustomobject]@{ + Name = 'reversed range' + Expected = $false + Finding = '{"location":{"line":37,"range":{"start-line":38,"end-line":36}}}' | ConvertFrom-Json + } + [pscustomobject]@{ + Name = 'zero bound' + Expected = $false + Finding = '{"location":{"line":1,"range":{"start-line":0,"end-line":2}}}' | ConvertFrom-Json + } + [pscustomobject]@{ + Name = 'fractional primary line' + Expected = $false + Finding = '{"location":{"line":37.5,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json + } + [pscustomobject]@{ + Name = 'missing end line' + Expected = $false + Finding = '{"location":{"line":37,"range":{"start-line":36}}}' | ConvertFrom-Json + } +) + +foreach ($case in $cases) { + $actual = Test-RangeNormalizationEligibility $case.Finding + Assert-True ($actual -eq $case.Expected) "$($case.Name) eligibility is $($case.Expected)" +} + +$rawFinding = $cases[0].Finding +$candidateFinding = $rawFinding | ConvertTo-Json -Depth 10 | ConvertFrom-Json +$candidateFinding.location.PSObject.Properties.Remove('range') + +Assert-True ($rawFinding.location.PSObject.Properties.Name -contains 'range') 'raw finding remains unchanged' +Assert-True (-not ($candidateFinding.location.PSObject.Properties.Name -contains 'range')) 'candidate removes only the optional range' +Assert-True ($candidateFinding.location.line -eq $rawFinding.location.line) 'candidate preserves the primary line' +Assert-True ($candidateFinding.message -ceq $rawFinding.message) 'candidate preserves all other finding content' + +Write-Output "Review contract validation passed ($($cases.Count) normalization cases)." diff --git a/tools/Test-ReviewFixtures.ps1 b/tools/Test-ReviewFixtures.ps1 index a9912b7..c4b0bf1 100644 --- a/tools/Test-ReviewFixtures.ps1 +++ b/tools/Test-ReviewFixtures.ps1 @@ -434,6 +434,7 @@ if ($PrepareDirectory) { } if (-not $ResultsPath -and -not $ResultsDirectory) { + & (Join-Path $PSScriptRoot 'Test-ReviewContract.ps1') -Root $Root Write-Host "Review fixture validation PASSED: $($cases.Count) cases cover $($leafDomains.Count) leaf domains." -ForegroundColor Green exit 0 } From 45ac371e7a8252f2ce7176060a640ea9506b320c Mon Sep 17 00:00:00 2001 From: Stefano Demiliani <33155438+demiliani@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:42:45 +0200 Subject: [PATCH 72/86] Add AL-focused AppSource validation guidance (#142) * knowledge(appsource): add AL validation guidance * Address Marketplace review feedback * Address remaining Marketplace review feedback * Align AppSource review applicability outcome --- .../define-profiles-as-al-objects.bad.al | 12 +++++++++ .../define-profiles-as-al-objects.good.al | 6 +++++ .../define-profiles-as-al-objects.md | 26 +++++++++++++++++++ .../do-not-hard-code-time-zone-offsets.bad.al | 7 +++++ ...do-not-hard-code-time-zone-offsets.good.al | 7 +++++ .../do-not-hard-code-time-zone-offsets.md | 26 +++++++++++++++++++ ...-web-service-paths-free-of-ui-calls.bad.al | 21 +++++++++++++++ ...web-service-paths-free-of-ui-calls.good.al | 15 +++++++++++ ...keep-web-service-paths-free-of-ui-calls.md | 26 +++++++++++++++++++ ...on-actions-with-addfirst-or-addlast.bad.al | 15 +++++++++++ ...n-actions-with-addfirst-or-addlast.good.al | 15 +++++++++++ ...ension-actions-with-addfirst-or-addlast.md | 26 +++++++++++++++++++ ...category-on-searchable-entry-points.bad.al | 20 ++++++++++++++ ...ategory-on-searchable-entry-points.good.al | 21 +++++++++++++++ ...sagecategory-on-searchable-entry-points.md | 26 +++++++++++++++++++ .../use-invariant-date-literals.bad.al | 10 +++++++ .../use-invariant-date-literals.good.al | 7 +++++ .../appsource/use-invariant-date-literals.md | 26 +++++++++++++++++++ .../skills/review/al-appsource-review.md | 14 +++++++--- 19 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 community/knowledge/appsource/define-profiles-as-al-objects.bad.al create mode 100644 community/knowledge/appsource/define-profiles-as-al-objects.good.al create mode 100644 community/knowledge/appsource/define-profiles-as-al-objects.md create mode 100644 community/knowledge/appsource/do-not-hard-code-time-zone-offsets.bad.al create mode 100644 community/knowledge/appsource/do-not-hard-code-time-zone-offsets.good.al create mode 100644 community/knowledge/appsource/do-not-hard-code-time-zone-offsets.md create mode 100644 community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.bad.al create mode 100644 community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.good.al create mode 100644 community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.md create mode 100644 community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.bad.al create mode 100644 community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.good.al create mode 100644 community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.md create mode 100644 community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.bad.al create mode 100644 community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.good.al create mode 100644 community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.md create mode 100644 community/knowledge/appsource/use-invariant-date-literals.bad.al create mode 100644 community/knowledge/appsource/use-invariant-date-literals.good.al create mode 100644 community/knowledge/appsource/use-invariant-date-literals.md diff --git a/community/knowledge/appsource/define-profiles-as-al-objects.bad.al b/community/knowledge/appsource/define-profiles-as-al-objects.bad.al new file mode 100644 index 0000000..20438c8 --- /dev/null +++ b/community/knowledge/appsource/define-profiles-as-al-objects.bad.al @@ -0,0 +1,12 @@ +codeunit 50100 "Rental Profile Install" +{ + Subtype = Install; + + trigger OnInstallAppPerDatabase() + var + RentalProfile: Record Profile; + begin + RentalProfile.Init(); + RentalProfile.Insert(true); + end; +} \ No newline at end of file diff --git a/community/knowledge/appsource/define-profiles-as-al-objects.good.al b/community/knowledge/appsource/define-profiles-as-al-objects.good.al new file mode 100644 index 0000000..23ed6bf --- /dev/null +++ b/community/knowledge/appsource/define-profiles-as-al-objects.good.al @@ -0,0 +1,6 @@ +profile "RENTAL MANAGER" +{ + Caption = 'Rental Manager'; + Description = 'Manages rental agreements and equipment availability.'; + RoleCenter = "Business Manager Role Center"; +} \ No newline at end of file diff --git a/community/knowledge/appsource/define-profiles-as-al-objects.md b/community/knowledge/appsource/define-profiles-as-al-objects.md new file mode 100644 index 0000000..6f34aee --- /dev/null +++ b/community/knowledge/appsource/define-profiles-as-al-objects.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: appsource +keywords: [profile-object, profile-table, install-codeunit, role-center, page-customization] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Define profiles as AL objects + +## Description + +Profiles delivered by a Marketplace extension must be declared as AL `profile` objects. A profile object is validated with its Role Center and page customizations when the extension is compiled and is registered through extension synchronization. Inserting profile-table records from install or setup code bypasses that object lifecycle. + +## Best Practice + +Declare each app-owned profile with the `profile` object and set its `RoleCenter`, user-facing caption, and optional customizations in AL. Let installation and synchronization register the object. + +See sample: [`define-profiles-as-al-objects.good.al`](define-profiles-as-al-objects.good.al). + +## Anti Pattern + +Install, upgrade, or setup code that creates an app-owned profile by inserting a `Profile` table record. Detection signal: a `Record Profile` variable followed by `Insert` in profile provisioning code. + +See sample: [`define-profiles-as-al-objects.bad.al`](define-profiles-as-al-objects.bad.al). \ No newline at end of file diff --git a/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.bad.al b/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.bad.al new file mode 100644 index 0000000..4ec258b --- /dev/null +++ b/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.bad.al @@ -0,0 +1,7 @@ +codeunit 50100 "Rental Audit" +{ + procedure SetCreatedAt(var RentalAgreement: Record "Rental Agreement") + begin + RentalAgreement."Created At" := CurrentDateTime() + 7200000; + end; +} \ No newline at end of file diff --git a/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.good.al b/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.good.al new file mode 100644 index 0000000..c4e155e --- /dev/null +++ b/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.good.al @@ -0,0 +1,7 @@ +codeunit 50100 "Rental Audit" +{ + procedure SetCreatedAt(var RentalAgreement: Record "Rental Agreement") + begin + RentalAgreement."Created At" := CurrentDateTime(); + end; +} \ No newline at end of file diff --git a/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.md b/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.md new file mode 100644 index 0000000..935b4d5 --- /dev/null +++ b/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: appsource +keywords: [datetime, time-zone, utc, currentdatetime, locale, regional-settings] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not hard-code time-zone offsets + +## Description + +Marketplace extensions run for users and services in many time zones. Adding a fixed offset to a `DateTime` assumes one locale, ignores daylight-saving transitions, and changes an absolute timestamp into an incorrect value for other regions. + +## Best Practice + +Store and compare `DateTime` values without a manually applied regional offset. Business Central stores `DateTime` values in UTC and presents them according to the client time zone. Keep service contracts time-zone explicit and perform a conversion only when the business requirement identifies a particular zone. + +See sample: [`do-not-hard-code-time-zone-offsets.good.al`](do-not-hard-code-time-zone-offsets.good.al). + +## Anti Pattern + +Adding or subtracting a fixed duration solely to convert `CurrentDateTime` or another timestamp to an assumed local time. Detection signals include fixed hour-sized millisecond values near `DateTime` assignments and comments naming a specific time zone; confirm the duration is an offset rather than a legitimate deadline or schedule interval. + +See sample: [`do-not-hard-code-time-zone-offsets.bad.al`](do-not-hard-code-time-zone-offsets.bad.al). \ No newline at end of file diff --git a/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.bad.al b/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.bad.al new file mode 100644 index 0000000..d062837 --- /dev/null +++ b/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.bad.al @@ -0,0 +1,21 @@ +codeunit 50100 "Rental Service" +{ + [ServiceEnabled] + procedure CloseAgreement(AgreementNo: Code[20]): Boolean + var + RentalAgreement: Record "Rental Agreement"; + begin + if not Confirm(CloseAgreementQst, false, AgreementNo) then + exit(false); + + RentalAgreement.Get(AgreementNo); + RentalAgreement.Closed := true; + RentalAgreement.Modify(true); + Message(AgreementClosedMsg, AgreementNo); + exit(true); + end; + + var + CloseAgreementQst: Label 'Close rental agreement %1?'; + AgreementClosedMsg: Label 'Rental agreement %1 was closed.'; +} \ No newline at end of file diff --git a/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.good.al b/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.good.al new file mode 100644 index 0000000..c990850 --- /dev/null +++ b/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.good.al @@ -0,0 +1,15 @@ +codeunit 50100 "Rental Service" +{ + [ServiceEnabled] + procedure CloseAgreement(AgreementNo: Code[20]): Boolean + var + RentalAgreement: Record "Rental Agreement"; + begin + if not RentalAgreement.Get(AgreementNo) then + exit(false); + + RentalAgreement.Closed := true; + RentalAgreement.Modify(true); + exit(true); + end; +} \ No newline at end of file diff --git a/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.md b/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.md new file mode 100644 index 0000000..45b06b2 --- /dev/null +++ b/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: appsource +keywords: [web-service, serviceenabled, guiallowed, message, confirm, strmenu] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep web-service paths free of UI calls + +## Description + +Pages and codeunits exposed as web services run without an interactive client. Calls that require a UI callback, including `Confirm`, `StrMenu`, and modal pages, can terminate the service request instead of completing the operation. `Message` does not raise the callback error: the message is suppressed and logged, making it ineffective for communicating a service result. + +## Best Practice + +Keep service entry points and every procedure they call free of interactive UI. Return data through the service contract and report validation failures with service-safe error handling. When a procedure is shared with an interactive client, guard UI-only behavior with `GuiAllowed` while preserving the underlying operation. + +See sample: [`keep-web-service-paths-free-of-ui-calls.good.al`](keep-web-service-paths-free-of-ui-calls.good.al). + +## Anti Pattern + +A web-service-exposed page or codeunit calls an interactive UI method directly or indirectly. Detection signals include `Message`, `Confirm`, `StrMenu`, `Page.RunModal`, and confirmation-dialog pages on a service call path. Treat `Message` as suppressed and ineffective, not as a callback failure. Do not flag a controlled `Error` solely because it returns a service fault. + +See sample: [`keep-web-service-paths-free-of-ui-calls.bad.al`](keep-web-service-paths-free-of-ui-calls.bad.al). \ No newline at end of file diff --git a/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.bad.al b/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.bad.al new file mode 100644 index 0000000..81e8f83 --- /dev/null +++ b/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.bad.al @@ -0,0 +1,15 @@ +pageextension 50100 "Rental Customer List" extends "Customer List" +{ + actions + { + addafter("Customer Ledger Entries") + { + action(OpenRentalAgreements) + { + ApplicationArea = All; + Caption = 'Rental Agreements'; + RunObject = page "Rental Agreement List"; + } + } + } +} \ No newline at end of file diff --git a/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.good.al b/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.good.al new file mode 100644 index 0000000..155a211 --- /dev/null +++ b/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.good.al @@ -0,0 +1,15 @@ +pageextension 50100 "Rental Customer List" extends "Customer List" +{ + actions + { + addlast(Processing) + { + action(OpenRentalAgreements) + { + ApplicationArea = All; + Caption = 'Rental Agreements'; + RunObject = page "Rental Agreement List"; + } + } + } +} \ No newline at end of file diff --git a/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.md b/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.md new file mode 100644 index 0000000..4967645 --- /dev/null +++ b/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: appsource +keywords: [pageextension, actions, addfirst, addlast, addbefore, addafter] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Place page extension actions with addfirst or addlast + +## Description + +Place new page-extension actions at the beginning or end of an existing action group with `addfirst` or `addlast`. Anchoring a new action relative to a specific base-app action with `addbefore` or `addafter` couples the extension to an implementation detail that can move or disappear between Business Central releases. + +## Best Practice + +Choose the semantic action area or group and append or prepend the extension's actions. This keeps placement deterministic without depending on the continued existence of one neighboring action. + +See sample: [`place-page-extension-actions-with-addfirst-or-addlast.good.al`](place-page-extension-actions-with-addfirst-or-addlast.good.al). + +## Anti Pattern + +Using `addbefore` or `addafter` to place newly added actions next to a specific action from another app. The syntax is valid AL, but the placement anchor is brittle for a Marketplace extension. + +See sample: [`place-page-extension-actions-with-addfirst-or-addlast.bad.al`](place-page-extension-actions-with-addfirst-or-addlast.bad.al). \ No newline at end of file diff --git a/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.bad.al b/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.bad.al new file mode 100644 index 0000000..d648f4a --- /dev/null +++ b/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.bad.al @@ -0,0 +1,20 @@ +page 50100 "Rental Agreement List" +{ + PageType = List; + SourceTable = "Rental Agreement"; + ApplicationArea = All; + + layout + { + area(Content) + { + repeater(Agreements) + { + field("No."; Rec."No.") + { + ApplicationArea = All; + } + } + } + } +} \ No newline at end of file diff --git a/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.good.al b/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.good.al new file mode 100644 index 0000000..97fe848 --- /dev/null +++ b/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.good.al @@ -0,0 +1,21 @@ +page 50100 "Rental Agreement List" +{ + PageType = List; + SourceTable = "Rental Agreement"; + ApplicationArea = All; + UsageCategory = Lists; + + layout + { + area(Content) + { + repeater(Agreements) + { + field("No."; Rec."No.") + { + ApplicationArea = All; + } + } + } + } +} \ No newline at end of file diff --git a/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.md b/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.md new file mode 100644 index 0000000..9241236 --- /dev/null +++ b/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: appsource +keywords: [usagecategory, tell-me, search, page, report, discoverability] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Set UsageCategory on searchable entry points + +## Description + +Pages and reports that users are expected to open directly must set `UsageCategory`. Without it, the object is absent from Tell Me and users cannot bookmark it from the web client. Supporting objects such as list parts, dialogs, API pages, and objects reached only through another page do not need to be searchable entry points. + +## Best Practice + +Set `UsageCategory` to the category that matches the entry point, such as `Lists`, `Tasks`, `ReportsAndAnalysis`, or `Documents`. Also set the appropriate object-level `ApplicationArea` so search results respect feature visibility. + +See sample: [`set-usagecategory-on-searchable-entry-points.good.al`](set-usagecategory-on-searchable-entry-points.good.al). + +## Anti Pattern + +A user-facing page or report intended for direct discovery omits `UsageCategory` or sets it to `None`. Do not infer intent from the object type alone; require evidence that the object is a direct user entry point. + +See sample: [`set-usagecategory-on-searchable-entry-points.bad.al`](set-usagecategory-on-searchable-entry-points.bad.al). \ No newline at end of file diff --git a/community/knowledge/appsource/use-invariant-date-literals.bad.al b/community/knowledge/appsource/use-invariant-date-literals.bad.al new file mode 100644 index 0000000..437083d --- /dev/null +++ b/community/knowledge/appsource/use-invariant-date-literals.bad.al @@ -0,0 +1,10 @@ +codeunit 50100 "Rental Period Defaults" +{ + procedure GetPolicyStartDate(): Date + var + PolicyStartDate: Date; + begin + Evaluate(PolicyStartDate, '01/31/2025'); + exit(PolicyStartDate); + end; +} \ No newline at end of file diff --git a/community/knowledge/appsource/use-invariant-date-literals.good.al b/community/knowledge/appsource/use-invariant-date-literals.good.al new file mode 100644 index 0000000..07b19c4 --- /dev/null +++ b/community/knowledge/appsource/use-invariant-date-literals.good.al @@ -0,0 +1,7 @@ +codeunit 50100 "Rental Period Defaults" +{ + procedure GetPolicyStartDate(): Date + begin + exit(20250131D); + end; +} \ No newline at end of file diff --git a/community/knowledge/appsource/use-invariant-date-literals.md b/community/knowledge/appsource/use-invariant-date-literals.md new file mode 100644 index 0000000..c38476c --- /dev/null +++ b/community/knowledge/appsource/use-invariant-date-literals.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: appsource +keywords: [date-literal, invariant-date, dateformula, localization, appsourcecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use invariant date literals + +## Description + +Write fixed dates in AL with the invariant `yyyymmddD` syntax. A locale-dependent text value parsed with `Evaluate` can change meaning or fail under another user's regional settings, which makes the Marketplace extension unreliable across markets. + +## Best Practice + +Represent a fixed date directly as an AL date literal, such as `20250131D`. Use `CalcDate` with a date formula when the value is relative rather than fixed. + +See sample: [`use-invariant-date-literals.good.al`](use-invariant-date-literals.good.al). + +## Anti Pattern + +Building a fixed date by passing localized text such as `01/02/2025` to `Evaluate`. Detection signal: `Evaluate` converting a hard-coded or label-backed formatted string into a `Date`. + +See sample: [`use-invariant-date-literals.bad.al`](use-invariant-date-literals.bad.al). \ No newline at end of file diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md index c851ea2..3f6a221 100644 --- a/microsoft/skills/review/al-appsource-review.md +++ b/microsoft/skills/review/al-appsource-review.md @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source and app metadata changes against the `appsource` 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 a `pr-diff`, `file-path`, or `folder-path`. AppSource findings are narrow by design — they apply to AppSource-facing metadata and complete permission coverage that requires repository context. Mechanical AppSourceCop diagnostics are intentionally outside this skill. The skill returns `not-applicable` when none of those surfaces apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. AppSource findings are narrow by design — they apply to Marketplace-facing metadata, complete permission coverage that requires repository context, and contextual AL constructs covered by Marketplace submission requirements. Mechanical compiler and analyzer diagnostics are intentionally outside this skill. The skill returns `not-applicable` when none of those surfaces apply. ## Source @@ -37,14 +37,20 @@ 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 files and AL object types — especially `app.json`, permission-set objects, setup and usage entry points, and AppSource-facing help metadata. -- Tokens extracted from the diff that relate to AppSource (`permissionset`, `Assignable`, `Permissions`, `SUPER`, `tabledata`, `execute`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`). +- The changed files and AL object types — especially `app.json`, permission-set and profile objects, setup and usage entry points, service-enabled procedures, user-facing pages and reports, and AppSource-facing help metadata. +- Tokens extracted from the diff that relate to AppSource (`permissionset`, `Assignable`, `Permissions`, `SUPER`, `tabledata`, `execute`, `profile`, `Record Profile`, `Evaluate`, `Date`, `DateTime`, `CurrentDateTime`, `UsageCategory`, `PageType`, `addfirst`, `addlast`, `addbefore`, `addafter`, `ServiceEnabled`, `GuiAllowed`, `Message`, `Confirm`, `StrMenu`, `RunModal`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no AppSource-related source or metadata changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. The following targeted checks cover every current `appsource` article across the Microsoft and community layers. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action. - The app has no assignable permission set covering its setup and usage paths, omits visible object/tabledata grants, or requires `SUPER` for normal operation — `permission-sets-cover-setup-and-usage-without-super`. Require repository-level app context; one isolated permission-set object cannot prove complete coverage. +- Install, upgrade, or setup code provisions an app-owned profile through `Record Profile` and `Insert` instead of declaring a `profile` object — `define-profiles-as-al-objects`. +- A hard-coded or label-backed formatted string is converted to `Date` with `Evaluate` — `use-invariant-date-literals`. Do not select this article for variable external input whose format must be validated at runtime. +- A page extension uses `addbefore` or `addafter` to place a newly added action relative to a specific action owned by another app — `place-page-extension-actions-with-addfirst-or-addlast`. Do not flag those keywords in layouts or placement relative to an action owned by the same extension. +- A page or codeunit web-service entry point, including a `[ServiceEnabled]` procedure, contains or reaches `Message`, `Confirm`, `StrMenu`, `Page.RunModal`, or a confirmation-dialog page without an effective non-GUI guard — `keep-web-service-paths-free-of-ui-calls`. Treat `Message` as suppressed and logged, making it ineffective as a service response; treat the other UI calls as callback-failure risks. Do not treat a controlled `Error` as interactive UI solely because it returns a service fault. +- A page or report that repository context identifies as a direct user entry point omits `UsageCategory` or sets it to `None` — `set-usagecategory-on-searchable-entry-points`. Do not select this article based only on object type; exclude supporting parts, dialogs, API pages, and objects intentionally reached through another page. +- A `DateTime` assignment adds or subtracts a fixed duration to represent an assumed regional offset — `do-not-hard-code-time-zone-offsets`. Require contextual evidence such as an hour-sized constant, offset-oriented name, or time-zone comment; do not flag deadlines, schedules, or elapsed-time calculations. - For BC v27 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent — `keep-copilot-help-url-to-two-path-levels`. 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`. @@ -75,7 +81,7 @@ Outcome selection: - `completed` — the skill evaluated every worklist item. - `no-knowledge` — no applicable AppSource knowledge survived filtering. -- `not-applicable` — the diff touches no AppSource permission or app-metadata surface. +- `not-applicable` — the diff touches no Marketplace-related source, permission, or app-metadata surface. - `partial` — a budget was hit before the worklist was exhausted. - `failed` — an unrecoverable error occurred. From 8c26ba4e7640fd613e60a40734c744f0f3bda40b Mon Sep 17 00:00:00 2001 From: Stefano Demiliani <33155438+demiliani@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:44:30 +0200 Subject: [PATCH 73/86] Add Job Queue reliability and scheduling guidance (#148) * knowledge(performance): add job queue reliability guidance * Address Job Queue review feedback * Address Job Queue routing review feedback * Encode job queue conflict in fixtures --- evaluation/README.md | 4 +- evaluation/review-fixtures.json | 10 ++- ...nt-inside-write-transaction-holds-locks.md | 2 +- ...ry-code-serializes-conflicting-jobs.bad.al | 11 +++ ...y-code-serializes-conflicting-jobs.good.al | 18 +++++ ...tegory-code-serializes-conflicting-jobs.md | 28 +++++++ ...external-effects-must-be-idempotent.bad.al | 57 ++++++++++++++ ...xternal-effects-must-be-idempotent.good.al | 65 ++++++++++++++++ ...eue-external-effects-must-be-idempotent.md | 30 ++++++++ ...-queue-handlers-must-not-require-ui.bad.al | 17 ++++ ...queue-handlers-must-not-require-ui.good.al | 14 ++++ .../job-queue-handlers-must-not-require-ui.md | 28 +++++++ ...ue-handlers-must-propagate-failures.bad.al | 23 ++++++ ...e-handlers-must-propagate-failures.good.al | 16 ++++ ...-queue-handlers-must-propagate-failures.md | 28 +++++++ ...-on-hold-does-not-stop-running-work.bad.al | 18 +++++ ...on-hold-does-not-stop-running-work.good.al | 49 ++++++++++++ ...ueue-on-hold-does-not-stop-running-work.md | 28 +++++++ ...ncompanyopen-subscribers-must-not-do-io.md | 2 +- ...ed-task-id-to-avoid-duplicate-tasks.bad.al | 15 ++++ ...d-task-id-to-avoid-duplicate-tasks.good.al | 23 ++++++ ...eduled-task-id-to-avoid-duplicate-tasks.md | 28 +++++++ .../skills/review/al-performance-review.md | 8 +- tools/Test-ReviewFixtures.ps1 | 77 ++++++++++++++----- 24 files changed, 574 insertions(+), 25 deletions(-) create mode 100644 microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.bad.al create mode 100644 microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.good.al create mode 100644 microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md create mode 100644 microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.bad.al create mode 100644 microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.good.al create mode 100644 microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md create mode 100644 microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.bad.al create mode 100644 microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.good.al create mode 100644 microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md create mode 100644 microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.bad.al create mode 100644 microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.good.al create mode 100644 microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md create mode 100644 microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.bad.al create mode 100644 microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.good.al create mode 100644 microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md create mode 100644 microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al create mode 100644 microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.good.al create mode 100644 microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md diff --git a/evaluation/README.md b/evaluation/README.md index 7be55b4..2125ce3 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -2,7 +2,7 @@ The evaluation is convention-driven. The harness discovers every `/skills/review/al--review.md` leaf across the enabled `microsoft`, `community`, and `custom` layers. Duplicate domains resolve with `custom > community > microsoft` precedence. For each selected leaf, the harness finds paired knowledge across the same layers, applies the same precedence to duplicate article slugs, selects the first article (by filename) with both `.bad.al` and `.good.al` companions, and derives the expected positive and clean control automatically. Adding a conforming leaf requires no scoring-contract edit. -`review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different article or add context when the generic convention cannot express a scenario. It should remain empty in the normal case. +`review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different `article`, add context when the generic convention cannot express a scenario, or use an `articles` array when one domain needs explicit regression coverage for several paired articles. Specify either `article` or `articles`, not both. The first selected article retains the stable `-bad` and `-good` manifest IDs; additional articles use slug-qualified IDs. Overrides should remain empty in the normal case. Model-facing preparation hashes case IDs, neutralizes `Good`/`Bad` object-name tokens, and removes full-line sample comments so neither the article slug, domain, nor expected outcome reveals the answer. @@ -26,7 +26,7 @@ This credential-free check proves every selected leaf maps to a same-named knowl 2. For a fast/small model, use one fresh invocation per `request-case-*.json`. Each request embeds the exact leaf instructions, that domain's candidate index rows with authoritative paths, and one opaque case. The model opens only matching articles and copies finding IDs from `candidateArticles[].path`. Save each response with the matching `result-case-*.json` name in the same directory. - `request-.json` files provide optional two-case leaf batches and identify the selected layer-owned skill path; save those as `result-.json`. Directory scoring prefers `result-case-*.json` when present and otherwise falls back to `result-*.json`. `review-request.json` is an optional all-domains stress test for larger models. Neither batch form is the preferred fast-model profile. + `request-.json` files provide optional leaf batches containing every selected case for that domain and identify the selected layer-owned skill path; save those as `result-.json`. A normal convention-selected domain has one bad/good pair, while an `articles` override contributes one pair per listed article. Directory scoring prefers `result-case-*.json` when present and otherwise falls back to `result-*.json`. `review-request.json` is an optional all-domains stress test for larger models. Neither batch form is the preferred fast-model profile. 3. Save only this result shape: diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index e11508a..352fccf 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -17,7 +17,15 @@ "article": "set-defaultimplementation-on-enum" }, "performance": { - "article": "use-isempty-for-existence-check" + "articles": [ + "use-isempty-for-existence-check", + "job-queue-category-code-serializes-conflicting-jobs", + "job-queue-external-effects-must-be-idempotent", + "job-queue-handlers-must-not-require-ui", + "job-queue-handlers-must-propagate-failures", + "job-queue-on-hold-does-not-stop-running-work", + "store-scheduled-task-id-to-avoid-duplicate-tasks" + ] }, "privacy": { "article": "no-pii-in-telemetry-message-string" diff --git a/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md b/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md index 21a2c04..88d0ff7 100644 --- a/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md +++ b/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md @@ -17,7 +17,7 @@ The first database write opens an AL write transaction that the runtime holds un ## Best Practice -Defer the HTTP call to a separate session. When the external operation must correspond to a committed database change, insert an outbox work item in the same transaction as that change and process committed outbox rows with a recurring job queue entry. The change and work item then commit or roll back together, and the worker performs HTTP before deleting the item so it holds no write lock during the call. Make the external operation idempotent because a failure after a successful HTTP response can cause the work item to be retried. +Defer the HTTP call to a separate session. When the external operation must correspond to a committed database change, insert an outbox work item in the same transaction as that change and process committed outbox rows with a recurring job queue entry. The change and work item then commit or roll back together, and the worker performs HTTP before deleting the item so it holds no write lock during the call. The separate retry-safety requirement is covered by `job-queue-external-effects-must-be-idempotent.md`. A directly created scheduled task is suitable only when its work is independent of the caller's commit. An immediately ready task can run concurrently with the caller, so it must not assume that the caller's writes are already committed. Do **not** use `Commit()` as a general remedy: it irrevocably commits all prior writes in the current transaction, so any subsequent failure cannot roll them back. `Commit()` is appropriate only at top-level entry points where partial persistence is intentional and understood. diff --git a/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.bad.al b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.bad.al new file mode 100644 index 0000000..fbd0b39 --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.bad.al @@ -0,0 +1,11 @@ +codeunit 50113 "Job Queue Category Bad" +{ + procedure ConfigureJobsForSharedExclusiveResource(var SalesPostingJob: Record "Job Queue Entry"; var PurchasePostingJob: Record "Job Queue Entry"; ExclusiveResourceId: Text[250]) + begin + // Both jobs update the same posting resources, but nothing prevents overlap. + SalesPostingJob.Validate("Parameter String", ExclusiveResourceId); + PurchasePostingJob.Validate("Parameter String", ExclusiveResourceId); + SalesPostingJob.Validate("Job Queue Category Code", ''); + PurchasePostingJob.Validate("Job Queue Category Code", ''); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.good.al b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.good.al new file mode 100644 index 0000000..f77200b --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.good.al @@ -0,0 +1,18 @@ +codeunit 50113 "Job Queue Category Good" +{ + procedure ConfigureJobsForSharedExclusiveResource(var SalesPostingJob: Record "Job Queue Entry"; var PurchasePostingJob: Record "Job Queue Entry"; ExclusiveResourceId: Text[250]) + var + JobQueueCategory: Record "Job Queue Category"; + begin + if not JobQueueCategory.Get('POSTING') then begin + JobQueueCategory.Code := 'POSTING'; + JobQueueCategory.Insert(); + end; + + // The shared category lets only one conflicting posting job run at a time. + SalesPostingJob.Validate("Parameter String", ExclusiveResourceId); + PurchasePostingJob.Validate("Parameter String", ExclusiveResourceId); + SalesPostingJob.Validate("Job Queue Category Code", 'POSTING'); + PurchasePostingJob.Validate("Job Queue Category Code", 'POSTING'); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md new file mode 100644 index 0000000..d92647e --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [job-queue, category-code, concurrency, waiting, serialization, locking] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use a job queue category to serialize conflicting jobs + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Different job queue entries can run at the same time. When two jobs update the same exclusive resource, concurrent execution can cause lock contention, deadlocks, or conflicting results. Within one company, entries with the same Job Queue Category Code are serialized: while one runs, another entry in that category waits. + +## Best Practice + +Assign the same non-empty Job Queue Category Code to job queue entries in the same company that must not overlap, regardless of which codeunit they run. Define categories around the shared resource or exclusivity requirement, not merely around object names. Leave independent jobs in different categories so they can still run concurrently. A category does not serialize work across companies or environments, or coordinate workers outside the job queue dispatcher. Protect shared external or cross-company resources with a separate application-level locking mechanism. + +See sample: `job-queue-category-code-serializes-conflicting-jobs.good.al`. + +## Anti Pattern + +Creating or configuring multiple job queue entries that update the same exclusive resource while leaving their Job Queue Category Code empty or different. Do not flag jobs merely because they touch the same tables; the rule applies when their operation requires mutual exclusion. + +See sample: `job-queue-category-code-serializes-conflicting-jobs.bad.al`. \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.bad.al b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.bad.al new file mode 100644 index 0000000..8f92f10 --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.bad.al @@ -0,0 +1,57 @@ +table 50112 "Queued Export Bad" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) + { + AutoIncrement = true; + } + field(2; Payload; Text[250]) + { + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } +} + +codeunit 50112 "Queued Export Worker Bad" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + var + QueuedExport: Record "Queued Export Bad"; + Client: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + begin + if not QueuedExport.FindFirst() then + exit; + + Content.WriteFrom(QueuedExport.Payload); + Client.Post('https://example.local/exports', Content, Response); + if not Response.IsSuccessStatusCode() then + Error('Export failed with HTTP status %1.', Response.HttpStatusCode()); + + // If this local step fails, the external export exists but this row is retried. + UpdateLocalStatus(); + FinalizeExport(QueuedExport); + end; + + local procedure UpdateLocalStatus() + begin + end; + + local procedure FinalizeExport(var QueuedExport: Record "Queued Export Bad") + begin + QueuedExport.Delete(); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.good.al b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.good.al new file mode 100644 index 0000000..d161089 --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.good.al @@ -0,0 +1,65 @@ +table 50112 "Queued Export Good" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) + { + AutoIncrement = true; + } + field(2; Payload; Text[250]) + { + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } + +} + +codeunit 50112 "Queued Export Worker Good" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + var + QueuedExport: Record "Queued Export Good"; + Client: HttpClient; + Content: HttpContent; + ContentHeaders: HttpHeaders; + JsonPayload: JsonObject; + RequestBody: Text; + Response: HttpResponseMessage; + begin + if not QueuedExport.FindFirst() then + exit; + + JsonPayload.Add('idempotencyKey', Format(QueuedExport.SystemId)); + JsonPayload.Add('payload', QueuedExport.Payload); + JsonPayload.WriteTo(RequestBody); + + Content.WriteFrom(RequestBody); + Content.GetHeaders(ContentHeaders); + ContentHeaders.Clear(); + ContentHeaders.Add('Content-Type', 'application/json'); + Client.Post('https://example.local/exports', Content, Response); + if not Response.IsSuccessStatusCode() then + Error('Export failed with HTTP status %1.', Response.HttpStatusCode()); + + // The external service must atomically create a record only when idempotencyKey + // does not exist. When the key already exists, it must return the existing record + // without repeating the side effect. + UpdateLocalStatus(); + QueuedExport.Delete(); + end; + + local procedure UpdateLocalStatus() + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md new file mode 100644 index 0000000..a5932df --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: performance +keywords: [job-queue, idempotency, retry, outbox, httpclient, external-effect] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Job queue external effects must be idempotent + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +A job queue handler can successfully create something in an external system and then fail while updating Business Central. Business Central rolls back its database changes, but it cannot roll back the external request. The same work can later run again through configured retries, recurrence, rescheduling, or manual restart. Without a way for the external system to recognize the repeated request, a later run can create a duplicate shipment, payment, notification, or other side effect. + +## Best Practice + +Use a stable request ID that exists before the job queue processes the outbox row. For example, include the outbox record's `SystemId` as an `idempotencyKey` value in the JSON body of every POST attempt. The external service must enforce uniqueness on that value: when it receives the key again, it returns the existing record instead of creating another one. Delete the outbox row only after the external call and all required local updates succeed. + +A `Processed` flag set after the external call does not solve this failure window. If a later AL error rolls back that flag, the outbox row again looks unprocessed even though the external operation already happened. + +See sample: `job-queue-external-effects-must-be-idempotent.good.al`. + +## Anti Pattern + +Sending a state-changing request from a job queue handler with no stable request ID understood by the external API. Specifically, look for this sequence: read an outbox row, call `HttpClient.Post` or another side-effecting API, update or delete local data, and propagate an error after which the same outbox row can be processed again. The key may be part of the request body, URI, headers, or an existing business key; a naturally idempotent remote operation is already safe and should not be flagged. + +See sample: `job-queue-external-effects-must-be-idempotent.bad.al`. \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.bad.al b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.bad.al new file mode 100644 index 0000000..eecfeaf --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.bad.al @@ -0,0 +1,17 @@ +codeunit 50110 "Job Queue UI Bad" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + begin + if not Confirm('Process the queued export now?') then + exit; + + ProcessExport(Rec."Parameter String"); + Message('The queued export completed.'); + end; + + local procedure ProcessExport(ParameterString: Text) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.good.al b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.good.al new file mode 100644 index 0000000..490720e --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.good.al @@ -0,0 +1,14 @@ +codeunit 50110 "Job Queue UI Good" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + begin + Rec.TestField("Parameter String"); + ProcessExport(Rec."Parameter String"); + end; + + local procedure ProcessExport(ParameterString: Text) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md new file mode 100644 index 0000000..f780ab5 --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [job-queue, background-session, guiallowed, confirm, runmodal, client-callback] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Job queue handlers must not require user interaction + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +A job queue handler runs in a background session with no client UI. Calls that require a client callback, such as `Confirm`, `Page.RunModal`, `Report.RunModal`, upload, or download, can stop the job with a non-retriable callback error. `Message` is suppressed and logged by the server, so it cannot communicate a result to the user who scheduled the job. + +## Best Practice + +Make a dedicated job queue entry point non-interactive. Validate parameters and data in AL, persist business-visible status when needed, and let failures propagate to the job queue log. If one procedure genuinely serves both foreground and background callers, isolate optional UI-only behavior behind `GuiAllowed`; do not use the guard to silently skip a decision that the operation requires. + +See sample: `job-queue-handlers-must-not-require-ui.good.al`. + +## Anti Pattern + +Calling `Confirm`, `Page.Run`, `Page.RunModal`, `Report.Run`, `Report.RunModal`, `Hyperlink`, `File.Upload`, or `File.Download` from a codeunit run by the job queue. Another signal is using `Message` as the only success or failure notification: no user is attached to receive it. + +See sample: `job-queue-handlers-must-not-require-ui.bad.al`. \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.bad.al b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.bad.al new file mode 100644 index 0000000..550506a --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.bad.al @@ -0,0 +1,23 @@ +codeunit 50111 "Job Queue Failure Bad" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + begin + if not TryProcessCustomer(Rec."Parameter String") then + exit; + end; + + [TryFunction] + local procedure TryProcessCustomer(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + Customer.Get(CustomerNo); + ProcessCustomer(Customer); + end; + + local procedure ProcessCustomer(Customer: Record Customer) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.good.al b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.good.al new file mode 100644 index 0000000..8a99875 --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.good.al @@ -0,0 +1,16 @@ +codeunit 50111 "Job Queue Failure Good" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + var + Customer: Record Customer; + begin + Customer.Get(Rec."Parameter String"); + ProcessCustomer(Customer); + end; + + local procedure ProcessCustomer(Customer: Record Customer) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md new file mode 100644 index 0000000..1c7b83f --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [job-queue, error-propagation, tryfunction, retry, dispatcher, job-queue-log] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Job queue handlers must propagate execution failures + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +The job queue dispatcher can mark an entry as failed, record the error, and apply its configured retry behavior only when the handler terminates with an error. A handler that catches a failed `TryFunction` or Boolean-returning operation and then returns normally reports success to the dispatcher, even though its work did not complete. + +## Best Practice + +Let an error that invalidates the whole run propagate out of the job queue entry point. Add context only when it helps an operator diagnose the failure and does not expose sensitive data. Per-item failures may be collected deliberately, but the batch must persist or emit an observable aggregate outcome instead of silently treating incomplete work as success. + +See sample: `job-queue-handlers-must-propagate-failures.good.al`. + +## Anti Pattern + +Calling a `TryFunction`, `Codeunit.Run`, or another Boolean-returning operation from a job queue handler and using `exit` or normal fall-through on failure without recording an intentional partial-success outcome. The dispatcher sees a successful return, so the entry's status and log do not represent the failed work and configured retries are not applied. + +See sample: `job-queue-handlers-must-propagate-failures.bad.al`. \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.bad.al b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.bad.al new file mode 100644 index 0000000..434f2b9 --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.bad.al @@ -0,0 +1,18 @@ +codeunit 50114 "Job Queue On Hold Bad" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + begin + repeat + if not ProcessNextBatch() then + exit; + Rec.Get(Rec.ID); + until Rec.Status = Rec.Status::"On Hold"; + end; + + local procedure ProcessNextBatch(): Boolean + begin + exit(false); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.good.al b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.good.al new file mode 100644 index 0000000..b924297 --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.good.al @@ -0,0 +1,49 @@ +table 50114 "Job Cancellation Control" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Job Queue Entry ID"; Guid) + { + } + field(2; "Stop Requested"; Boolean) + { + } + } + + keys + { + key(PK; "Job Queue Entry ID") + { + Clustered = true; + } + } +} + +codeunit 50114 "Job Queue On Hold Good" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + begin + while not IsStopRequested(Rec.ID) do + if not ProcessNextBatch() then + exit; + end; + + local procedure IsStopRequested(JobQueueEntryId: Guid): Boolean + var + JobCancellationControl: Record "Job Cancellation Control"; + begin + if not JobCancellationControl.Get(JobQueueEntryId) then + exit(false); + + exit(JobCancellationControl."Stop Requested"); + end; + + local procedure ProcessNextBatch(): Boolean + begin + exit(false); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md new file mode 100644 index 0000000..6eec05c --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [job-queue, on-hold, cancellation, in-process, long-running, stop-request] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Putting a job queue entry on hold does not stop its current run + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +The On Hold status prevents a job queue entry from starting again, but it does not cancel a run that is already in process. A long-running handler continues until it completes, fails, reaches a cancellation point implemented by the application, or its session is stopped externally. + +## Best Practice + +Use On Hold to pause future scheduling. When a long-running operation must support graceful cancellation, store a separate application-owned stop request and check it before every bounded unit of work, including the first. Exit only at a point where completed work and the checkpoint are consistent. The code that resumes scheduling must clear the stop request before restarting the job. Use administrative session termination only when graceful cancellation is impossible. + +See sample: `job-queue-on-hold-does-not-stop-running-work.good.al`. + +## Anti Pattern + +Polling the job queue entry's Status field from inside its handler and expecting a change to On Hold to cancel the active run. The status controls scheduling, not cooperative cancellation, so the handler can continue processing despite the operator's action. + +See sample: `job-queue-on-hold-does-not-stop-running-work.bad.al`. \ No newline at end of file diff --git a/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md b/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md index e8da1c5..95ac3d7 100644 --- a/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md +++ b/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md @@ -17,7 +17,7 @@ application-area: [all] ## Best Practice -Keep company-open subscribers to cheap in-memory work: set a flag, enqueue a job-queue entry, or `TaskScheduler.CreateTask`. Perform HTTP and large SQL after the session is running, in that background work. +Keep company-open subscribers to cheap in-memory work: set a flag, enqueue a job-queue entry, or `TaskScheduler.CreateTask`. Perform HTTP and large SQL after the session is running, in that background work. When the subscriber can run repeatedly, use `store-scheduled-task-id-to-avoid-duplicate-tasks.md` to avoid creating the same logical task more than once. See sample: [`oncompanyopen-subscribers-must-not-do-io.good.al`](oncompanyopen-subscribers-must-not-do-io.good.al). diff --git a/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al new file mode 100644 index 0000000..d692317 --- /dev/null +++ b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al @@ -0,0 +1,15 @@ +codeunit 50115 "Scheduled Task Duplicate Bad" +{ + procedure EnsureCleanupTask() + begin + // Every call creates another task for the same cleanup work. + TaskScheduler.CreateTask(Codeunit::"Scheduled Cleanup Work Bad", 0, true, CompanyName()); + end; +} + +codeunit 50116 "Scheduled Cleanup Work Bad" +{ + trigger OnRun() + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.good.al b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.good.al new file mode 100644 index 0000000..df52c62 --- /dev/null +++ b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.good.al @@ -0,0 +1,23 @@ +codeunit 50115 "Scheduled Task Duplicate Good" +{ + internal procedure EnsureCleanupTask() + var + TaskId: Guid; + StoredTaskId: Text; + begin + if IsolatedStorage.Get('CleanupTaskId', DataScope::Company, StoredTaskId) then + if Evaluate(TaskId, StoredTaskId) then + if TaskScheduler.TaskExists(TaskId) then + exit; + + TaskId := TaskScheduler.CreateTask(Codeunit::"Scheduled Cleanup Work Good", 0, true, CompanyName()); + IsolatedStorage.Set('CleanupTaskId', Format(TaskId), DataScope::Company); + end; +} + +codeunit 50116 "Scheduled Cleanup Work Good" +{ + trigger OnRun() + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md new file mode 100644 index 0000000..600c671 --- /dev/null +++ b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [task-scheduler, scheduled-task, taskexists, duplicate-task, createtask, guid] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Store the scheduled task ID to avoid duplicate tasks + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Every call to `TaskScheduler.CreateTask` creates a new scheduled task and returns its unique GUID. Repeating setup or lifecycle code without retaining that GUID can create multiple tasks for the same logical work, consuming scheduler capacity and running the work more than once. + +## Best Practice + +Persist the GUID returned by `CreateTask` at the same scope as the logical task. Before creating a replacement, parse the stored GUID and call `TaskScheduler.TaskExists`; create and store a new task only when the previous task no longer exists. `TaskExists` checks one GUID, not whether an equivalent codeunit is already scheduled, so callers that can schedule concurrently still need serialization around this check-and-create sequence. + +See sample: `store-scheduled-task-id-to-avoid-duplicate-tasks.good.al`. + +## Anti Pattern + +Calling `TaskScheduler.CreateTask` every time initialization, login, setup, or another repeatable path runs while ignoring its return value. Each invocation creates another independent task even when an equivalent task is already pending. + +See sample: `store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al`. \ No newline at end of file diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index 664f20d..7829d70 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, SetAutoCalcFields, CalcSums, FlowField access, Commit calls, checkpoint helpers, record copying, RecordRef conversion, Modify/Delete calls, or cross-table navigation. -- Tokens extracted from the diff that relate to data access and hot-path costs (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`, `FlowField`, `Visible`). +- Tokens extracted from the diff that relate to data access, hot-path costs, and background scheduling (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`, `FlowField`, `Visible`, `Job Queue Entry`, `Job Queue Category Code`, `Confirm`, `RunModal`, `GuiAllowed`, `TryFunction`, `Codeunit.Run`, `HttpClient`, `Status`, `On Hold`, `stop request`, `TaskScheduler.CreateTask`, `TaskScheduler.TaskExists`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. @@ -52,6 +52,12 @@ Apply these targeted cues even when simple token overlap would rank the article - Worklist `avoid-cloning-records-before-modify-delete-in-loops.md` when an iteration calls `Copy` or `RecordRef.GetTable` before `Modify`/`Delete`, or passes the iterated record without `var` to a helper that writes that record. Do not worklist it from `Modify`, `Delete`, or `RecordRef` alone; exclude a direct write on the iterator, a read-only copy, a temporary record, a different target table, and a `RecordRef` opened and iterated directly. - Worklist `use-tryfunction-for-error-catching-not-rollback.md` only when writes occur inside a try method and the code or surrounding flow expects an error to roll them back. A bare try-method call whose Boolean result is ignored belongs exclusively to `error-handling/ignored-tryfunction-return-disables-try-semantics.md`; do not worklist the performance article from that call shape alone. - For `LockTable` in a pure read helper, select exactly one owner. Use `do-not-locktable-in-read-only-procedure.md` when the helper needs no stronger isolation and should remove the lock. Use `prefer-readisolation-over-locktable-for-reads.md` instead when the code explicitly requires committed-read semantics and `ReadIsolation` is the replacement. Never emit both findings for the same call. +- Worklist `job-queue-handlers-must-not-require-ui.md` when a codeunit run by the job queue calls `Confirm`, `Page.Run`, `Page.RunModal`, `Report.Run`, `Report.RunModal`, `Hyperlink`, `File.Upload`, or `File.Download`, or uses `Message` as its only success or failure notification. Exclude optional UI-only behavior guarded by `GuiAllowed`; do not exclude a guard that silently skips a decision required by the operation. +- Worklist `job-queue-handlers-must-propagate-failures.md` when a codeunit run by the job queue handles a failed `TryFunction`, `Codeunit.Run`, or another Boolean-returning operation with `exit` or normal fall-through, causing the dispatcher to observe success. Exclude intentional partial-success handling that persists or emits an observable aggregate outcome. A bare try-method call whose Boolean result is ignored remains owned exclusively by `error-handling/ignored-tryfunction-return-disables-try-semantics.md`. +- Worklist `job-queue-external-effects-must-be-idempotent.md` when rerunnable job queue work reads an outbox row, performs a state-changing external request, then updates or deletes local data without sending a stable request ID understood by the external system. Exclude naturally idempotent operations and requests whose body, URI, headers, or business key lets the external service return the existing result instead of repeating the side effect. +- Worklist `job-queue-on-hold-does-not-stop-running-work.md` when a running job queue handler polls the entry's `Status` or `On Hold` value as a cancellation signal. Exclude application-owned stop requests that are checked before every bounded unit of work, including the first, when completed work and its checkpoint remain consistent and resume logic clears the request. +- Worklist `job-queue-category-code-serializes-conflicting-jobs.md` when two or more job queue entries in the same company are shown by the changed context to require mutual exclusion but have empty or different Job Queue Category Codes. Do not infer a conflict merely because jobs touch the same tables, and do not recommend a category to coordinate across companies, environments, or workers outside the job queue dispatcher. +- Worklist `store-scheduled-task-id-to-avoid-duplicate-tasks.md` when `TaskScheduler.CreateTask` runs from initialization, login, setup, or another repeatable path without persisting its returned GUID and checking it with `TaskScheduler.TaskExists` before creating a replacement. Exclude one-shot creation and correctly persisted check-before-create flows; concurrent callers still require serialization around that sequence. These targeted inclusions and exclusions override generic token overlap. Do not retain an excluded article solely because the diff contains one of its keywords. diff --git a/tools/Test-ReviewFixtures.ps1 b/tools/Test-ReviewFixtures.ps1 index c4b0bf1..8cf019f 100644 --- a/tools/Test-ReviewFixtures.ps1 +++ b/tools/Test-ReviewFixtures.ps1 @@ -195,12 +195,42 @@ foreach ($domain in $leafDomains) { } $override = if ($overrides.ContainsKey($domain)) { $overrides[$domain] } else { $null } - $selectedArticle = $null - if ($override -and ($override.PSObject.Properties.Name -contains 'article')) { - $articleName = [string]$override.article + $hasArticleOverride = $override -and ($override.PSObject.Properties.Name -contains 'article') + $hasArticlesOverride = $override -and ($override.PSObject.Properties.Name -contains 'articles') + if ($hasArticleOverride -and $hasArticlesOverride) { + $problems.Add("${domain}: override must specify either 'article' or 'articles', not both.") | Out-Null + continue + } + + $articleNames = @() + if ($hasArticlesOverride) { + $articleNames = @($override.articles) + if (-not $articleNames.Count) { + $problems.Add("${domain}: override 'articles' must contain at least one article.") | Out-Null + continue + } + } elseif ($hasArticleOverride) { + $articleNames = @($override.article) + } else { + $articleNames = @($articles | Select-Object -First 1 | ForEach-Object BaseName) + } + + $selectedArticles = [System.Collections.Generic.List[object]]::new() + $seenArticleNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($articleNameValue in $articleNames) { + if ($articleNameValue -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$articleNameValue)) { + $problems.Add("${domain}: override article names must be non-empty strings.") | Out-Null + continue + } + $articleName = [string]$articleNameValue if ($articleName.EndsWith('.md')) { $articleName = [System.IO.Path]::GetFileNameWithoutExtension($articleName) } + if (-not $seenArticleNames.Add($articleName)) { + $problems.Add("${domain}: override contains duplicate article: $articleName.md") | Out-Null + continue + } + $selectedArticle = $articles | Where-Object BaseName -eq $articleName | Select-Object -First 1 if (-not $selectedArticle) { $articleExists = @( @@ -218,32 +248,41 @@ foreach ($domain in $leafDomains) { } continue } - } else { - $selectedArticle = $articles | Select-Object -First 1 + $selectedArticles.Add($selectedArticle) | Out-Null } - if (-not $selectedArticle) { - $problems.Add("${domain}: no article has both .good.al and .bad.al companion samples.") | Out-Null + if (-not $selectedArticles.Count) { + if (-not $articleNames.Count) { + $problems.Add("${domain}: no article has both .good.al and .bad.al companion samples.") | Out-Null + } continue } - $articlePath = [string]$selectedArticle.ArticlePath - $sampleDirectory = (Split-Path -Parent $articlePath).Replace('\', '/') $context = if ($override -and ($override.PSObject.Properties.Name -contains 'context')) { [string]$override.context } else { $null } - foreach ($kind in 'bad', 'good') { - $case = [pscustomobject]@{ - id = "$domain-$kind" - domain = $domain - input = "$sampleDirectory/$($selectedArticle.BaseName).$kind.al" - expected = if ($kind -eq 'bad') { @($articlePath) } else { @() } + for ($articleIndex = 0; $articleIndex -lt $selectedArticles.Count; $articleIndex++) { + $selectedArticle = $selectedArticles[$articleIndex] + $articlePath = [string]$selectedArticle.ArticlePath + $sampleDirectory = (Split-Path -Parent $articlePath).Replace('\', '/') + foreach ($kind in 'bad', 'good') { + $caseId = if ($articleIndex -eq 0) { + "$domain-$kind" + } else { + "$domain-$($selectedArticle.BaseName)-$kind" + } + $case = [pscustomobject]@{ + id = $caseId + domain = $domain + input = "$sampleDirectory/$($selectedArticle.BaseName).$kind.al" + expected = if ($kind -eq 'bad') { @($articlePath) } else { @() } + } + if ($context) { + $case | Add-Member -NotePropertyName context -NotePropertyValue $context + } + $caseList.Add($case) | Out-Null } - if ($context) { - $case | Add-Member -NotePropertyName context -NotePropertyValue $context - } - $caseList.Add($case) | Out-Null } } $cases = @($caseList) From 852a6762857cdd2a13d675ea0245e24471914e95 Mon Sep 17 00:00:00 2001 From: dayland <48474707+dayland@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:32:40 +0200 Subject: [PATCH 74/86] Fix Job Queue sample links (#184) Use the required READ-convention Markdown links so knowledge retrieval can associate all new samples with their articles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: dayland Copilot-Session: 76eb42c4-2acd-4f9c-a898-f4a44f9d46f7 --- .../job-queue-category-code-serializes-conflicting-jobs.md | 4 ++-- .../job-queue-external-effects-must-be-idempotent.md | 4 ++-- .../performance/job-queue-handlers-must-not-require-ui.md | 4 ++-- .../performance/job-queue-handlers-must-propagate-failures.md | 4 ++-- .../job-queue-on-hold-does-not-stop-running-work.md | 4 ++-- .../store-scheduled-task-id-to-avoid-duplicate-tasks.md | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md index d92647e..190fb5e 100644 --- a/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md +++ b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md @@ -19,10 +19,10 @@ Different job queue entries can run at the same time. When two jobs update the s Assign the same non-empty Job Queue Category Code to job queue entries in the same company that must not overlap, regardless of which codeunit they run. Define categories around the shared resource or exclusivity requirement, not merely around object names. Leave independent jobs in different categories so they can still run concurrently. A category does not serialize work across companies or environments, or coordinate workers outside the job queue dispatcher. Protect shared external or cross-company resources with a separate application-level locking mechanism. -See sample: `job-queue-category-code-serializes-conflicting-jobs.good.al`. +See sample: [`job-queue-category-code-serializes-conflicting-jobs.good.al`](job-queue-category-code-serializes-conflicting-jobs.good.al). ## Anti Pattern Creating or configuring multiple job queue entries that update the same exclusive resource while leaving their Job Queue Category Code empty or different. Do not flag jobs merely because they touch the same tables; the rule applies when their operation requires mutual exclusion. -See sample: `job-queue-category-code-serializes-conflicting-jobs.bad.al`. \ No newline at end of file +See sample: [`job-queue-category-code-serializes-conflicting-jobs.bad.al`](job-queue-category-code-serializes-conflicting-jobs.bad.al). \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md index a5932df..66bb11b 100644 --- a/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md +++ b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md @@ -21,10 +21,10 @@ Use a stable request ID that exists before the job queue processes the outbox ro A `Processed` flag set after the external call does not solve this failure window. If a later AL error rolls back that flag, the outbox row again looks unprocessed even though the external operation already happened. -See sample: `job-queue-external-effects-must-be-idempotent.good.al`. +See sample: [`job-queue-external-effects-must-be-idempotent.good.al`](job-queue-external-effects-must-be-idempotent.good.al). ## Anti Pattern Sending a state-changing request from a job queue handler with no stable request ID understood by the external API. Specifically, look for this sequence: read an outbox row, call `HttpClient.Post` or another side-effecting API, update or delete local data, and propagate an error after which the same outbox row can be processed again. The key may be part of the request body, URI, headers, or an existing business key; a naturally idempotent remote operation is already safe and should not be flagged. -See sample: `job-queue-external-effects-must-be-idempotent.bad.al`. \ No newline at end of file +See sample: [`job-queue-external-effects-must-be-idempotent.bad.al`](job-queue-external-effects-must-be-idempotent.bad.al). \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md index f780ab5..5987270 100644 --- a/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md +++ b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md @@ -19,10 +19,10 @@ A job queue handler runs in a background session with no client UI. Calls that r Make a dedicated job queue entry point non-interactive. Validate parameters and data in AL, persist business-visible status when needed, and let failures propagate to the job queue log. If one procedure genuinely serves both foreground and background callers, isolate optional UI-only behavior behind `GuiAllowed`; do not use the guard to silently skip a decision that the operation requires. -See sample: `job-queue-handlers-must-not-require-ui.good.al`. +See sample: [`job-queue-handlers-must-not-require-ui.good.al`](job-queue-handlers-must-not-require-ui.good.al). ## Anti Pattern Calling `Confirm`, `Page.Run`, `Page.RunModal`, `Report.Run`, `Report.RunModal`, `Hyperlink`, `File.Upload`, or `File.Download` from a codeunit run by the job queue. Another signal is using `Message` as the only success or failure notification: no user is attached to receive it. -See sample: `job-queue-handlers-must-not-require-ui.bad.al`. \ No newline at end of file +See sample: [`job-queue-handlers-must-not-require-ui.bad.al`](job-queue-handlers-must-not-require-ui.bad.al). \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md index 1c7b83f..361fc7d 100644 --- a/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md +++ b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md @@ -19,10 +19,10 @@ The job queue dispatcher can mark an entry as failed, record the error, and appl Let an error that invalidates the whole run propagate out of the job queue entry point. Add context only when it helps an operator diagnose the failure and does not expose sensitive data. Per-item failures may be collected deliberately, but the batch must persist or emit an observable aggregate outcome instead of silently treating incomplete work as success. -See sample: `job-queue-handlers-must-propagate-failures.good.al`. +See sample: [`job-queue-handlers-must-propagate-failures.good.al`](job-queue-handlers-must-propagate-failures.good.al). ## Anti Pattern Calling a `TryFunction`, `Codeunit.Run`, or another Boolean-returning operation from a job queue handler and using `exit` or normal fall-through on failure without recording an intentional partial-success outcome. The dispatcher sees a successful return, so the entry's status and log do not represent the failed work and configured retries are not applied. -See sample: `job-queue-handlers-must-propagate-failures.bad.al`. \ No newline at end of file +See sample: [`job-queue-handlers-must-propagate-failures.bad.al`](job-queue-handlers-must-propagate-failures.bad.al). \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md index 6eec05c..b0411a8 100644 --- a/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md +++ b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md @@ -19,10 +19,10 @@ The On Hold status prevents a job queue entry from starting again, but it does n Use On Hold to pause future scheduling. When a long-running operation must support graceful cancellation, store a separate application-owned stop request and check it before every bounded unit of work, including the first. Exit only at a point where completed work and the checkpoint are consistent. The code that resumes scheduling must clear the stop request before restarting the job. Use administrative session termination only when graceful cancellation is impossible. -See sample: `job-queue-on-hold-does-not-stop-running-work.good.al`. +See sample: [`job-queue-on-hold-does-not-stop-running-work.good.al`](job-queue-on-hold-does-not-stop-running-work.good.al). ## Anti Pattern Polling the job queue entry's Status field from inside its handler and expecting a change to On Hold to cancel the active run. The status controls scheduling, not cooperative cancellation, so the handler can continue processing despite the operator's action. -See sample: `job-queue-on-hold-does-not-stop-running-work.bad.al`. \ No newline at end of file +See sample: [`job-queue-on-hold-does-not-stop-running-work.bad.al`](job-queue-on-hold-does-not-stop-running-work.bad.al). \ No newline at end of file diff --git a/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md index 600c671..1e64c32 100644 --- a/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md +++ b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md @@ -19,10 +19,10 @@ Every call to `TaskScheduler.CreateTask` creates a new scheduled task and return Persist the GUID returned by `CreateTask` at the same scope as the logical task. Before creating a replacement, parse the stored GUID and call `TaskScheduler.TaskExists`; create and store a new task only when the previous task no longer exists. `TaskExists` checks one GUID, not whether an equivalent codeunit is already scheduled, so callers that can schedule concurrently still need serialization around this check-and-create sequence. -See sample: `store-scheduled-task-id-to-avoid-duplicate-tasks.good.al`. +See sample: [`store-scheduled-task-id-to-avoid-duplicate-tasks.good.al`](store-scheduled-task-id-to-avoid-duplicate-tasks.good.al). ## Anti Pattern Calling `TaskScheduler.CreateTask` every time initialization, login, setup, or another repeatable path runs while ignoring its return value. Each invocation creates another independent task even when an equivalent task is already pending. -See sample: `store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al`. \ No newline at end of file +See sample: [`store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al`](store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al). \ No newline at end of file From b74967bc5b7a454eae19d6a1250199afd869f064 Mon Sep 17 00:00:00 2001 From: dayland <48474707+dayland@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:27:40 +0200 Subject: [PATCH 75/86] Add machine-readable review contracts (#182) Generate a deterministic action-skill index from frontmatter, publish structural schemas for orchestration and findings, and validate flat review composition in CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: dayland Copilot-Session: 76eb42c4-2acd-4f9c-a898-f4a44f9d46f7 --- .github/scripts/Test-SkillIndex.ps1 | 240 +++++++++++++++++++++ .github/scripts/validate_frontmatter.py | 41 +++- .github/workflows/skill-index.yml | 18 ++ microsoft/skills/review/al-code-review.md | 5 + schemas/findings-report.schema.json | 159 ++++++++++++++ schemas/skill-index.schema.json | 84 ++++++++ skills/do.md | 12 ++ tools/Build-SkillIndex.ps1 | 247 ++++++++++++++++++++++ 8 files changed, 803 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/Test-SkillIndex.ps1 create mode 100644 .github/workflows/skill-index.yml create mode 100644 schemas/findings-report.schema.json create mode 100644 schemas/skill-index.schema.json create mode 100644 tools/Build-SkillIndex.ps1 diff --git a/.github/scripts/Test-SkillIndex.ps1 b/.github/scripts/Test-SkillIndex.ps1 new file mode 100644 index 0000000..839042f --- /dev/null +++ b/.github/scripts/Test-SkillIndex.ps1 @@ -0,0 +1,240 @@ +<# +.SYNOPSIS + Validates the BCQuality action-skill index generator and shared schemas. +#> +[CmdletBinding()] +param( + [string] $Root = (Resolve-Path (Join-Path -Path $PSScriptRoot -ChildPath '..' '..')) +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$Root = (Resolve-Path -LiteralPath $Root).Path + +function Assert-ThrowsLike { + param( + [scriptblock] $Action, + [string] $Pattern + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -like $Pattern) { + return + } + throw "Expected error like '$Pattern', received: $($_.Exception.Message)" + } + throw "Expected error like '$Pattern', but no error was thrown." +} + +$generator = Join-Path $Root 'tools/Build-SkillIndex.ps1' +$indexSchema = Join-Path $Root 'schemas/skill-index.schema.json' +$reportSchema = Join-Path $Root 'schemas/findings-report.schema.json' +foreach ($path in $generator, $indexSchema, $reportSchema) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Required contract file not found: $path" + } +} + +$tmp = Join-Path ([IO.Path]::GetTempPath()) ("skillindex_" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $tmp -Force | Out-Null +try { + $first = Join-Path $tmp 'first.json' + $second = Join-Path $tmp 'second.json' + & $generator -BCQualityRoot $Root -IndexPath $first | Out-Null + & $generator -BCQualityRoot $Root -IndexPath $second | Out-Null + + $normalize = { + param([string] $Path) + return ((Get-Content -LiteralPath $Path -Raw) -replace '"generatedAt":"[^"]*"', '"generatedAt":""') + } + if ((& $normalize $first) -ne (& $normalize $second)) { + throw 'Skill index is not deterministic beyond generatedAt.' + } + + $raw = Get-Content -LiteralPath $first -Raw + if (-not ($raw | Test-Json -SchemaFile $indexSchema -ErrorAction Stop)) { + throw 'Generated skill index does not satisfy schemas/skill-index.schema.json.' + } + + $index = $raw | ConvertFrom-Json + $skills = @($index.skills) + if ($index.skillCount -ne $skills.Count) { + throw "skillCount is $($index.skillCount), but the index contains $($skills.Count) records." + } + + $paths = @($skills.path) + $duplicates = @($paths | Group-Object | Where-Object Count -gt 1) + if ($duplicates.Count) { + throw "Duplicate skill paths: $($duplicates.Name -join ', ')" + } + foreach ($path in $paths) { + if (-not (Test-Path -LiteralPath (Join-Path $Root $path) -PathType Leaf)) { + throw "Indexed skill does not exist: $path" + } + } + + $expectedLeaves = @( + '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', + 'microsoft/skills/review/al-error-handling-review.md', + 'microsoft/skills/review/al-events-review.md', + 'microsoft/skills/review/al-interfaces-review.md', + 'microsoft/skills/review/al-breaking-changes-review.md', + 'microsoft/skills/review/al-web-services-review.md', + 'microsoft/skills/review/al-testing-review.md', + 'microsoft/skills/review/al-data-modeling-review.md', + 'microsoft/skills/review/al-query-review.md', + 'microsoft/skills/review/al-appsource-review.md', + 'microsoft/skills/review/al-telemetry-review.md' + ) + $review = @($skills | Where-Object id -eq 'al-code-review') + if ($review.Count -ne 1) { + throw "Expected exactly one al-code-review record, found $($review.Count)." + } + if ((@($review[0].subSkills) -join "`n") -cne ($expectedLeaves -join "`n")) { + throw 'al-code-review subSkills did not preserve the declared 16-leaf order.' + } + foreach ($leafPath in $expectedLeaves) { + $leaf = @($skills | Where-Object path -ceq $leafPath) + if ($leaf.Count -ne 1 -or @($leaf[0].subSkills).Count -ne 0) { + throw "Expected '$leafPath' to resolve to exactly one leaf action skill." + } + } + + $minimalReport = @{ + skill = @{ id = 'al-style-review'; version = 1 } + outcome = 'completed' + summary = @{ + counts = @{ blocker = 0; major = 0; minor = 0; info = 0 } + coverage = @{ 'worklist-size' = 0; 'items-evaluated' = 0 } + } + findings = @() + suppressed = @() + } | ConvertTo-Json -Depth 8 + if (-not ($minimalReport | Test-Json -SchemaFile $reportSchema -ErrorAction Stop)) { + throw 'Minimal findings report does not satisfy schemas/findings-report.schema.json.' + } + + $reviewSkillText = Get-Content -LiteralPath ( + Join-Path -Path $Root -ChildPath 'microsoft/skills/review/al-code-review.md' + ) -Raw + $reportExamples = [regex]::Matches($reviewSkillText, '(?s)```json\s*(\{.*?\})\s*```') + if ($reportExamples.Count -ne 2) { + throw "Expected two al-code-review JSON examples, found $($reportExamples.Count)." + } + foreach ($example in $reportExamples) { + if (-not ($example.Groups[1].Value | Test-Json -SchemaFile $reportSchema -ErrorAction Stop)) { + throw 'An al-code-review output example does not satisfy schemas/findings-report.schema.json.' + } + } + + $fixtureRoot = Join-Path -Path $tmp -ChildPath 'fixture' + $fixtureSkills = Join-Path -Path $fixtureRoot -ChildPath 'microsoft/skills/review' + New-Item -ItemType Directory -Path $fixtureSkills -Force | Out-Null + $leaf = @' +--- +kind: action-skill +id: al-leaf-review +version: 1 +title: Leaf +description: Test leaf. +inputs: [file-path] +outputs: [findings-report] +--- + +# Leaf + +## Source +Source. +## Relevance +Relevance. +## Worklist +Worklist. +## Action +Action. +## Output +Output. +'@ + Set-Content -LiteralPath (Join-Path $fixtureSkills 'al-leaf-review.md') -Value $leaf -Encoding utf8NoBOM + + $duplicateSuper = @' +--- +kind: action-skill +id: al-code-review +version: 1 +title: Review +description: Test super-skill. +inputs: [file-path] +outputs: [findings-report] +sub-skills: + - microsoft/skills/review/al-leaf-review.md + - microsoft/skills/review/al-leaf-review.md +--- + +# Review + +## Source +Source. +## Relevance +Relevance. +## Worklist +Worklist. +## Action +Action. +## Output +Output. +'@ + $superPath = Join-Path $fixtureSkills 'al-code-review.md' + Set-Content -LiteralPath $superPath -Value $duplicateSuper -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*duplicate sub-skill*' -Action { + & $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'invalid.json') + } + + $nestedLeaf = $leaf.Replace('id: al-leaf-review', 'id: al-nested-review').Replace( + 'outputs: [findings-report]', + "outputs: [findings-report]`nsub-skills:`n - microsoft/skills/review/al-leaf-review.md" + ) + Set-Content -LiteralPath (Join-Path $fixtureSkills 'al-nested-review.md') -Value $nestedLeaf -Encoding utf8NoBOM + $nestedSuper = @' +--- +kind: action-skill +id: al-code-review +version: 1 +title: Review +description: Test super-skill. +inputs: [file-path] +outputs: [findings-report] +sub-skills: + - microsoft/skills/review/al-nested-review.md +--- + +# Review + +## Source +Source. +## Relevance +Relevance. +## Worklist +Worklist. +## Action +Action. +## Output +Output. +'@ + Set-Content -LiteralPath $superPath -Value $nestedSuper -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*Nested super-skills are not supported*' -Action { + & $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'nested.json') + } +} +finally { + Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Output 'Skill-index check PASSED: deterministic, schema-valid, and all 16 review leaves preserved in order.' diff --git a/.github/scripts/validate_frontmatter.py b/.github/scripts/validate_frontmatter.py index f422d53..20f33d6 100644 --- a/.github/scripts/validate_frontmatter.py +++ b/.github/scripts/validate_frontmatter.py @@ -381,6 +381,20 @@ def validate_action_skill(path: Path, parsed: Parsed, report: Report) -> None: bad = [x for x in ss if not x.endswith(".md")] if bad: report.error(path, "R20", f"sub-skills entries must end in '.md': {bad}", 1) + non_canonical = [ + x for x in ss + if "\\" in x or x.startswith("/") or ".." in Path(x).parts or x.startswith("./") + ] + if non_canonical: + report.error( + path, + "R20", + f"sub-skills entries must be canonical repo-relative paths: {non_canonical}", + 1, + ) + duplicates = sorted({x for x in ss if ss.count(x) > 1}) + if duplicates: + report.error(path, "R20", f"sub-skills contains duplicate paths: {duplicates}", 1) # R21 five required sections, in order, each exactly once heads = [h for h, _ in headings_in_order(parsed.body)] @@ -565,7 +579,13 @@ class SkillRecord: skill_id: str | None -def validate_sub_skills_registry(path: Path, fm: dict[str, Any], root: Path, report: Report) -> None: +def validate_sub_skills_registry( + path: Path, + fm: dict[str, Any], + root: Path, + action_skills_by_path: dict[str, dict[str, Any]], + report: Report, +) -> None: """R26: a super-skill's declared `sub-skills` must exactly match the `al-*-review.md` leaf files present in the same directory (set equality, ordering-agnostic). This keeps the registered leaf list the single source @@ -598,6 +618,17 @@ def validate_sub_skills_registry(path: Path, fm: dict[str, Any], root: Path, rep f"sub-skills entry is not a sibling 'al-*-review.md' leaf: {entry}", 1, ) + for entry in ss: + leaf = action_skills_by_path.get(entry) + if leaf is None: + if (root / entry).exists(): + report.error(path, "R26", f"sub-skills entry is not an action skill: {entry}", 1) + continue + if is_non_empty_list_of_str(leaf.get("sub-skills")): + report.error(path, "R26", f"nested super-skill is not permitted in v1 composition: {entry}", 1) + if leaf.get("outputs") != ["findings-report"]: + report.error(path, "R26", f"sub-skill must produce findings-report: {entry}", 1) + # Sibling leaves on disk that were never registered ('forgot to wire it up'). for leaf in sorted(leaves - declared): report.error(path, "R26", f"leaf not registered in sub-skills: {leaf}", 1) @@ -670,9 +701,13 @@ def run(root: Path) -> Report: others = [q.relative_to(root).as_posix() for q in paths if q != p] report.error(p, "R24", f"skill id '{sid}' ({kind}) is not unique; also defined in: {others}") - # Fourth pass: R26 sub-skills registry matches leaf files on disk + # Fourth pass: R26 sub-skills registry matches compatible leaf files on disk + action_skills_by_path = { + path.relative_to(root).as_posix(): fm + for path, fm in action_skill_fms + } for path, fm in action_skill_fms: - validate_sub_skills_registry(path, fm, root, report) + validate_sub_skills_registry(path, fm, root, action_skills_by_path, report) return report diff --git a/.github/workflows/skill-index.yml b/.github/workflows/skill-index.yml new file mode 100644 index 0000000..b1b8e3a --- /dev/null +++ b/.github/workflows/skill-index.yml @@ -0,0 +1,18 @@ +name: Validate skill index and report schemas + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + validate-contract: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Validate skill-index generator and schemas + shell: pwsh + run: ./.github/scripts/Test-SkillIndex.ps1 -Root . diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 9239220..9b5f739 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -41,6 +41,11 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat The sub-skills invoked by this skill are those listed in frontmatter `sub-skills`. Additional leaf skills are added by updating the `sub-skills` list. The skill does not discover sub-skills implicitly. +Hosts that orchestrate leaves mechanically SHOULD run +`tools/Build-SkillIndex.ps1` and resolve this skill by `id: al-code-review`. +The generated `subSkills` array preserves the frontmatter order and avoids +host-specific Markdown parsing. + ## Relevance A sub-skill is relevant when both of the following hold: diff --git a/schemas/findings-report.schema.json b/schemas/findings-report.schema.json new file mode 100644 index 0000000..76712f4 --- /dev/null +++ b/schemas/findings-report.schema.json @@ -0,0 +1,159 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/microsoft/BCQuality/schemas/findings-report.schema.json", + "title": "BCQuality findings report", + "type": "object", + "additionalProperties": false, + "required": ["skill", "outcome", "summary", "findings", "suppressed"], + "properties": { + "skill": { "$ref": "#/definitions/skillReference" }, + "outcome": { + "enum": ["completed", "not-applicable", "no-knowledge", "partial", "failed"] + }, + "outcome-reason": { "type": "string", "minLength": 1 }, + "summary": { "$ref": "#/definitions/summary" }, + "findings": { + "type": "array", + "items": { "$ref": "#/definitions/finding" } + }, + "suppressed": { + "type": "array", + "items": { "$ref": "#/definitions/suppressed" } + }, + "sub-results": { + "type": "array", + "items": { "$ref": "#" } + }, + "skipped-sub-skills": { + "type": "array", + "items": { "$ref": "#/definitions/skippedSubSkill" } + } + }, + "allOf": [ + { + "if": { + "properties": { + "outcome": { "enum": ["partial", "failed"] } + } + }, + "then": { "required": ["outcome-reason"] } + }, + { + "if": { + "properties": { + "outcome": { "enum": ["not-applicable", "no-knowledge", "failed"] } + } + }, + "then": { + "properties": { + "findings": { "maxItems": 0 } + } + } + } + ], + "definitions": { + "skillReference": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, + "version": { "type": "integer", "minimum": 1 } + } + }, + "counts": { + "type": "object", + "additionalProperties": false, + "required": ["blocker", "major", "minor", "info"], + "properties": { + "blocker": { "type": "integer", "minimum": 0 }, + "major": { "type": "integer", "minimum": 0 }, + "minor": { "type": "integer", "minimum": 0 }, + "info": { "type": "integer", "minimum": 0 } + } + }, + "coverage": { + "type": "object", + "additionalProperties": false, + "required": ["worklist-size", "items-evaluated"], + "properties": { + "worklist-size": { "type": "integer", "minimum": 0 }, + "items-evaluated": { "type": "integer", "minimum": 0 } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["counts", "coverage"], + "properties": { + "counts": { "$ref": "#/definitions/counts" }, + "coverage": { "$ref": "#/definitions/coverage" } + } + }, + "reference": { + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { + "path": { "type": "string", "minLength": 1, "pattern": "^[^\\\\]+$" }, + "sha": { "type": "string", "pattern": "^[a-fA-F0-9]{40}$" } + } + }, + "location": { + "type": "object", + "additionalProperties": false, + "required": ["file", "line"], + "properties": { + "file": { "type": "string", "minLength": 1, "pattern": "^[^\\\\]+$" }, + "line": { "type": "integer", "minimum": 1 }, + "range": { + "type": "object", + "additionalProperties": false, + "required": ["start-line", "end-line"], + "properties": { + "start-line": { "type": "integer", "minimum": 1 }, + "end-line": { "type": "integer", "minimum": 1 } + } + } + } + }, + "finding": { + "type": "object", + "additionalProperties": false, + "required": ["id", "severity", "message", "references", "confidence"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "severity": { "enum": ["blocker", "major", "minor", "info"] }, + "message": { "type": "string", "minLength": 1 }, + "location": { "$ref": "#/definitions/location" }, + "references": { + "type": "array", + "items": { "$ref": "#/definitions/reference" } + }, + "confidence": { "enum": ["high", "medium", "low"] }, + "from-sub-skill": { "type": "string", "minLength": 1 }, + "domain": { "type": "string", "minLength": 1, "pattern": "^[^\\r\\n]+$" }, + "suggested-code": { "type": "string", "minLength": 1 }, + "suggested-code-omission-reason": { "type": "string", "minLength": 1 } + } + }, + "suppressed": { + "type": "object", + "additionalProperties": false, + "required": ["reference", "reason"], + "properties": { + "reference": { "$ref": "#/definitions/reference" }, + "reason": { "enum": ["layer-precedence", "configuration"] } + } + }, + "skippedSubSkill": { + "type": "object", + "additionalProperties": false, + "required": ["skill", "reason"], + "properties": { + "skill": { "$ref": "#/definitions/skillReference" }, + "reason": { "enum": ["configuration", "not-applicable"] } + } + } + } +} diff --git a/schemas/skill-index.schema.json b/schemas/skill-index.schema.json new file mode 100644 index 0000000..01944bb --- /dev/null +++ b/schemas/skill-index.schema.json @@ -0,0 +1,84 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/microsoft/BCQuality/schemas/skill-index.schema.json", + "title": "BCQuality action-skill index", + "type": "object", + "additionalProperties": false, + "required": ["version", "generatedAt", "skillCount", "sourceSnapshot", "skills"], + "properties": { + "version": { "const": 1 }, + "generatedAt": { "type": "string", "format": "date-time" }, + "skillCount": { "type": "integer", "minimum": 0 }, + "sourceSnapshot": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "skills": { + "type": "array", + "items": { "$ref": "#/definitions/skill" } + } + }, + "definitions": { + "stringArray": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "skill": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "layer", + "id", + "version", + "title", + "description", + "inputs", + "outputs", + "filters", + "subSkills", + "sourceSha256" + ], + "properties": { + "path": { "type": "string", "pattern": "^(microsoft|community|custom)/skills/.+\\.md$" }, + "layer": { "enum": ["microsoft", "community", "custom"] }, + "id": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, + "version": { "type": "integer", "minimum": 1 }, + "title": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "inputs": { "$ref": "#/definitions/stringArray" }, + "outputs": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { "const": "findings-report" } + }, + "filters": { + "type": "object", + "additionalProperties": false, + "required": ["bc-version", "technologies", "countries", "application-area"], + "properties": { + "bc-version": { + "type": "array", + "items": { + "oneOf": [ + { "type": "integer", "minimum": 1 }, + { "type": "string", "pattern": "^(all|[1-9][0-9]*\\.\\.[1-9][0-9]*|[1-9][0-9]*\\.\\.)$" } + ] + } + }, + "technologies": { "$ref": "#/definitions/stringArray" }, + "countries": { "$ref": "#/definitions/stringArray" }, + "application-area": { "$ref": "#/definitions/stringArray" } + } + }, + "subSkills": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^(microsoft|community|custom)/skills/.+\\.md$" + } + }, + "sourceSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + } + } +} diff --git a/skills/do.md b/skills/do.md index 9abdf58..e67faf6 100644 --- a/skills/do.md +++ b/skills/do.md @@ -106,6 +106,12 @@ Every action skill MUST contain these five sections, in order: Every action skill emits a single JSON document that conforms to this schema: +The machine-readable structural schema is +[`schemas/findings-report.schema.json`](../schemas/findings-report.schema.json). +The rules below remain authoritative for semantic checks that JSON Schema +cannot perform by itself, including summary arithmetic, reference existence, +source-scope locations, and article-body retrieval. + ```json { "skill": { "id": "string", "version": 1 }, @@ -349,6 +355,12 @@ the declared worklist order, and wait for every invocation to finish before performing any super-skill self-review or final rollup. Scheduling MUST NOT change relevance, coverage, failure, reference-integrity, or output semantics. +Orchestrators SHOULD generate `skill-index.json` with +`tools/Build-SkillIndex.ps1` and consume the super-skill's ordered `subSkills` +from that index instead of parsing Markdown. Action-skill frontmatter remains +the source of truth; the generated index conforms to +`schemas/skill-index.schema.json`. + ### Section interpretation for super-skills The five required sections still apply. Their meaning shifts from knowledge files to sub-skills: diff --git a/tools/Build-SkillIndex.ps1 b/tools/Build-SkillIndex.ps1 new file mode 100644 index 0000000..022898e --- /dev/null +++ b/tools/Build-SkillIndex.ps1 @@ -0,0 +1,247 @@ +<# +.SYNOPSIS + Builds the machine-readable BCQuality action-skill index. + +.DESCRIPTION + Action-skill frontmatter remains the source of truth. This script emits the + versioned JSON contract orchestrators consume so they do not need to parse + Markdown or duplicate composition rules. + +.PARAMETER BCQualityRoot + BCQuality repository or filtered content root. + +.PARAMETER IndexPath + Output path. Defaults to /skill-index.json. + +.OUTPUTS + Returns the number of indexed action skills. +#> +[CmdletBinding()] +param( + [string] $BCQualityRoot, + [string] $IndexPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $BCQualityRoot) { + $BCQualityRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +} +if (-not (Test-Path -LiteralPath $BCQualityRoot -PathType Container)) { + throw "BCQuality root not found: $BCQualityRoot" +} +$BCQualityRoot = (Resolve-Path -LiteralPath $BCQualityRoot).Path +if (-not $IndexPath) { + $IndexPath = Join-Path $BCQualityRoot 'skill-index.json' +} + +function Get-RelativePath { + param([string] $Root, [string] $Full) + + return ($Full.Substring($Root.Length).TrimStart([char]'/', [char]'\') -replace '\\', '/') +} + +function Get-Sha256 { + param([byte[]] $Bytes) + + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant() + } + finally { + $sha.Dispose() + } +} + +function Get-ValueSha256 { + param([Parameter(Mandatory)] $Value) + + return Get-Sha256 -Bytes ([Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject $Value -Depth 12 -Compress) + )) +} + +function ConvertFrom-SkillFrontmatter { + param( + [string] $Path, + [string] $Text + ) + + $lines = [regex]::Split($Text.TrimStart([char]0xfeff), '\r\n|\n|\r') + if ($lines.Count -lt 3 -or $lines[0].Trim() -ne '---') { + throw [IO.InvalidDataException]::new("Missing frontmatter in '$Path'.") + } + + $end = -1 + for ($i = 1; $i -lt $lines.Count; $i++) { + if ($lines[$i].Trim() -eq '---') { + $end = $i + break + } + } + if ($end -lt 0) { + throw [IO.InvalidDataException]::new("Unterminated frontmatter in '$Path'.") + } + + $frontmatter = [ordered]@{} + for ($i = 1; $i -lt $end; $i++) { + $line = $lines[$i] + if ($line -notmatch '^([a-zA-Z][\w-]*)\s*:\s*(.*)$') { + continue + } + + $key = $Matches[1] + $value = $Matches[2].Trim() + if ($value -eq '') { + $items = [System.Collections.Generic.List[string]]::new() + while ($i + 1 -lt $end -and $lines[$i + 1] -match '^\s+-\s+(.+?)\s*$') { + $i++ + $items.Add($Matches[1].Trim().Trim('"', "'")) | Out-Null + } + $frontmatter[$key] = @($items) + continue + } + + if ($value -match '^\[(.*)\]$') { + $inner = $Matches[1].Trim() + $values = [System.Collections.Generic.List[object]]::new() + if ($inner) { + foreach ($item in $inner -split '\s*,\s*') { + $normalized = $item.Trim().Trim('"', "'") + $number = 0 + if ($key -eq 'bc-version' -and [int]::TryParse($normalized, [ref]$number)) { + $values.Add($number) | Out-Null + } + else { + $values.Add($normalized) | Out-Null + } + } + } + $frontmatter[$key] = [object[]]@($values) + continue + } + + $frontmatter[$key] = $value.Trim('"', "'") + } + + return $frontmatter +} + +$records = [System.Collections.Generic.List[object]]::new() +$recordsByPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) +$sourceManifest = [System.Collections.Generic.List[object]]::new() + +foreach ($layer in 'microsoft', 'community', 'custom') { + $skillsRoot = Join-Path $BCQualityRoot (Join-Path $layer 'skills') + if (-not (Test-Path -LiteralPath $skillsRoot -PathType Container)) { + continue + } + + foreach ($file in Get-ChildItem -LiteralPath $skillsRoot -Recurse -File -Filter '*.md' | Sort-Object FullName) { + $bytes = [IO.File]::ReadAllBytes($file.FullName) + try { + $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) + } + catch [Text.DecoderFallbackException] { + throw [IO.InvalidDataException]::new("Invalid UTF-8 in '$($file.FullName)'.", $_.Exception) + } + + $frontmatter = ConvertFrom-SkillFrontmatter -Path $file.FullName -Text $text + if ($frontmatter['kind'] -ne 'action-skill') { + continue + } + + foreach ($required in 'id', 'version', 'title', 'description', 'inputs', 'outputs') { + if (-not $frontmatter.Contains($required) -or $null -eq $frontmatter[$required] -or + ([string]$frontmatter[$required]).Trim() -eq '') { + throw [IO.InvalidDataException]::new( + "Action skill '$($file.FullName)' is missing required frontmatter '$required'." + ) + } + } + + $path = Get-RelativePath -Root $BCQualityRoot -Full $file.FullName + $sourceSha256 = Get-Sha256 -Bytes $bytes + $version = 0 + if (-not [int]::TryParse([string]$frontmatter['version'], [ref]$version) -or $version -le 0) { + throw [IO.InvalidDataException]::new("Action skill '$path' has an invalid version.") + } + + $subSkills = @() + if ($frontmatter.Contains('sub-skills')) { + $subSkills = @($frontmatter['sub-skills']) + if (-not $subSkills.Count) { + throw [IO.InvalidDataException]::new("Super-skill '$path' has an empty sub-skills list.") + } + } + + $record = [pscustomobject][ordered]@{ + path = $path + layer = $layer + id = [string]$frontmatter['id'] + version = $version + title = [string]$frontmatter['title'] + description = [string]$frontmatter['description'] + inputs = [string[]]@($frontmatter['inputs']) + outputs = [string[]]@($frontmatter['outputs']) + filters = [ordered]@{ + 'bc-version' = [object[]]$(if ($frontmatter.Contains('bc-version')) { $frontmatter['bc-version'] }) + technologies = [string[]]$(if ($frontmatter.Contains('technologies')) { $frontmatter['technologies'] }) + countries = [string[]]$(if ($frontmatter.Contains('countries')) { $frontmatter['countries'] }) + 'application-area' = [string[]]$(if ($frontmatter.Contains('application-area')) { $frontmatter['application-area'] }) + } + subSkills = [string[]]$subSkills + sourceSha256 = $sourceSha256 + } + + if (-not $recordsByPath.TryAdd($path, $record)) { + throw "Duplicate action-skill path: $path" + } + $records.Add($record) | Out-Null + $sourceManifest.Add([ordered]@{ path = $path; sha256 = $sourceSha256 }) | Out-Null + } +} + +$ids = @($records | Group-Object id | Where-Object Count -gt 1) +if ($ids.Count) { + throw "Duplicate action-skill IDs: $($ids.Name -join ', ')" +} + +foreach ($record in $records) { + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($subSkillPath in @($record.subSkills)) { + if (-not $seen.Add($subSkillPath)) { + throw "Super-skill '$($record.path)' declares duplicate sub-skill '$subSkillPath'." + } + if (-not $recordsByPath.ContainsKey($subSkillPath)) { + throw "Super-skill '$($record.path)' references missing action skill '$subSkillPath'." + } + + $leaf = $recordsByPath[$subSkillPath] + if (@($leaf.subSkills).Count) { + throw "Nested super-skills are not supported: '$($record.path)' references '$subSkillPath'." + } + if (@($leaf.outputs).Count -ne 1 -or $leaf.outputs[0] -ne 'findings-report') { + throw "Sub-skill '$subSkillPath' must produce findings-report." + } + } +} + +$index = [ordered]@{ + version = 1 + generatedAt = (Get-Date).ToUniversalTime().ToString('o') + skillCount = $records.Count + sourceSnapshot = Get-ValueSha256 -Value @($sourceManifest) + skills = @($records) +} + +$parent = Split-Path -Parent $IndexPath +if ($parent -and -not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null +} +Set-Content -LiteralPath $IndexPath -Value ( + ConvertTo-Json -InputObject $index -Depth 12 -Compress +) -Encoding utf8NoBOM + +return $records.Count From b545b22fb9173e0a1f17c3b2e02dc77ea6c92dab Mon Sep 17 00:00:00 2001 From: Stefano Demiliani <33155438+demiliani@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:38:32 +0200 Subject: [PATCH 76/86] Add reporting review guidance and evaluation fixtures (#183) * knowledge(performance): add job queue reliability guidance * Address Job Queue review feedback * Address Job Queue routing review feedback * Encode job queue conflict in fixtures * Add reporting review guidance and fixtures * Fix reporting article reference --- docs/using-bcquality.md | 3 +- evaluation/review-fixtures.json | 13 ++++ ...ariable-before-independent-runmodal.bad.al | 16 +++++ ...riable-before-independent-runmodal.good.al | 17 +++++ ...rt-variable-before-independent-runmodal.md | 32 ++++++++++ ...port-break-ends-the-current-trigger.bad.al | 31 +++++++++ ...ort-break-ends-the-current-trigger.good.al | 31 +++++++++ ...rrreport-break-ends-the-current-trigger.md | 30 +++++++++ ...t-rolls-back-and-skips-onpostreport.bad.al | 27 ++++++++ ...-rolls-back-and-skips-onpostreport.good.al | 28 +++++++++ ...-quit-rolls-back-and-skips-onpostreport.md | 30 +++++++++ ...ort-skip-does-not-stop-trigger-code.bad.al | 26 ++++++++ ...rt-skip-does-not-stop-trigger-code.good.al | 28 +++++++++ ...rreport-skip-does-not-stop-trigger-code.md | 30 +++++++++ ...in-a-loop-needs-one-client-download.bad.al | 14 +++++ ...n-a-loop-needs-one-client-download.good.al | 37 +++++++++++ ...put-in-a-loop-needs-one-client-download.md | 32 ++++++++++ ...-dataitem-trigger-order-is-explicit.bad.al | 30 +++++++++ ...dataitem-trigger-order-is-explicit.good.al | 30 +++++++++ ...sion-dataitem-trigger-order-is-explicit.md | 32 ++++++++++ ...rt-triggers-run-after-base-triggers.bad.al | 31 +++++++++ ...t-triggers-run-after-base-triggers.good.al | 37 +++++++++++ ...report-triggers-run-after-base-triggers.md | 30 +++++++++ ...ew-cannot-broaden-dataitemtableview.bad.al | 26 ++++++++ ...w-cannot-broaden-dataitemtableview.good.al | 26 ++++++++ ...leview-cannot-broaden-dataitemtableview.md | 30 +++++++++ ...equestpage-returns-empty-parameters.bad.al | 17 +++++ ...questpage-returns-empty-parameters.good.al | 20 ++++++ ...runrequestpage-returns-empty-parameters.md | 30 +++++++++ microsoft/skills/review/al-code-review.md | 1 + .../skills/review/al-reporting-review.md | 63 +++++++++++++++++++ 31 files changed, 827 insertions(+), 1 deletion(-) create mode 100644 microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.bad.al create mode 100644 microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.good.al create mode 100644 microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.md create mode 100644 microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.bad.al create mode 100644 microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.good.al create mode 100644 microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.md create mode 100644 microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.bad.al create mode 100644 microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.good.al create mode 100644 microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.md create mode 100644 microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.bad.al create mode 100644 microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.good.al create mode 100644 microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.md create mode 100644 microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.bad.al create mode 100644 microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.good.al create mode 100644 microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.md create mode 100644 microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.bad.al create mode 100644 microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.good.al create mode 100644 microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.md create mode 100644 microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.bad.al create mode 100644 microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.good.al create mode 100644 microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.md create mode 100644 microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.bad.al create mode 100644 microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.good.al create mode 100644 microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.md create mode 100644 microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.bad.al create mode 100644 microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.good.al create mode 100644 microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.md create mode 100644 microsoft/skills/review/al-reporting-review.md diff --git a/docs/using-bcquality.md b/docs/using-bcquality.md index dcaddde..e55751f 100644 --- a/docs/using-bcquality.md +++ b/docs/using-bcquality.md @@ -183,7 +183,7 @@ using your normal compilation, analyzer, test, and human-review workflow. ## Coverage and limits -The Microsoft broad review composes the 16 Microsoft domains listed below. +The Microsoft broad review composes the 17 Microsoft domains listed below. The Community Agents review is a separate skill selected by the request, not a nested part of that coordinator. All current review leaves accept app folders, files, and diffs; request an Agent SDK review explicitly when that @@ -219,6 +219,7 @@ Each article describes one concern. Where samples exist, use its linked | Performance | [Performance](../microsoft/knowledge/performance/) | | Privacy | [Privacy](../microsoft/knowledge/privacy/) | | Query objects | [Query](../microsoft/knowledge/query/) | +| Reporting | [Reporting](../microsoft/knowledge/reporting/) | | Security | [Security](../microsoft/knowledge/security/) | | Style | [Style](../microsoft/knowledge/style/) | | Telemetry | [Telemetry](../microsoft/knowledge/telemetry/) | diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 352fccf..3eeed68 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -30,6 +30,19 @@ "privacy": { "article": "no-pii-in-telemetry-message-string" }, + "reporting": { + "articles": [ + "clear-report-variable-before-independent-runmodal", + "currreport-break-ends-the-current-trigger", + "currreport-quit-rolls-back-and-skips-onpostreport", + "currreport-skip-does-not-stop-trigger-code", + "report-output-in-a-loop-needs-one-client-download", + "reportextension-dataitem-trigger-order-is-explicit", + "reportextension-report-triggers-run-after-base-triggers", + "settableview-cannot-broaden-dataitemtableview", + "stop-when-runrequestpage-returns-empty-parameters" + ] + }, "style": { "article": "label-comment-explains-placeholders" }, diff --git a/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.bad.al b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.bad.al new file mode 100644 index 0000000..736ddbf --- /dev/null +++ b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.bad.al @@ -0,0 +1,16 @@ +codeunit 50102 "Run Customer Reports" +{ + procedure RunBlockedAndUnblockedCustomers() + var + Customer: Record Customer; + CustomerList: Report "Customer - List"; + begin + Customer.SetRange(Blocked, Customer.Blocked::All); + CustomerList.SetTableView(Customer); + CustomerList.RunModal(); + + Customer.SetRange(Blocked, Customer.Blocked::" "); + CustomerList.SetTableView(Customer); + CustomerList.RunModal(); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.good.al b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.good.al new file mode 100644 index 0000000..51e5a0e --- /dev/null +++ b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.good.al @@ -0,0 +1,17 @@ +codeunit 50102 "Run Customer Reports" +{ + procedure RunBlockedAndUnblockedCustomers() + var + Customer: Record Customer; + CustomerList: Report "Customer - List"; + begin + Customer.SetRange(Blocked, Customer.Blocked::All); + CustomerList.SetTableView(Customer); + CustomerList.RunModal(); + + Clear(CustomerList); + Customer.SetRange(Blocked, Customer.Blocked::" "); + CustomerList.SetTableView(Customer); + CustomerList.RunModal(); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.md b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.md new file mode 100644 index 0000000..b82c1fb --- /dev/null +++ b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: reporting +keywords: [report, runmodal, clear, settableview, instance, state, filters] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Clear a Report variable before an independent RunModal execution + +## Description + +`Report.Run()` automatically clears the report variable after execution, but `Report.RunModal()` does not. Reconfiguring and running the same variable for an independent operation can therefore retain filters and other instance state from the previous run. + +## Best Practice + +Call `Clear(ReportVariable)` before configuring a new, logically independent `RunModal()` execution on a reused report variable. No clear is required after a single execution, and retaining state is valid when the subsequent run intentionally continues with the same configuration. + +See sample: [`clear-report-variable-before-independent-runmodal.good.al`](clear-report-variable-before-independent-runmodal.good.al). + +## Anti Pattern + +Run the same report variable modally for two independent views without clearing it between runs. The second `SetTableView` can only narrow the existing report view, so filters retained by the instance can make the second result incomplete or empty. + +See sample: [`clear-report-variable-before-independent-runmodal.bad.al`](clear-report-variable-before-independent-runmodal.bad.al). + +## References + +`Report.RunModal()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-runmodal-method + +`Report.Run()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-run-method \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.bad.al b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.bad.al new file mode 100644 index 0000000..dd88abc --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.bad.al @@ -0,0 +1,31 @@ +report 50105 "Customer Entry Review" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnAfterGetRecord() + var + EntryNo: Integer; + begin + repeat + EntryNo += 1; + if EntryNo = 5 then + CurrReport.Break(); + until EntryNo = 10; + + MarkCustomerReviewed(); + end; + } + } + + local procedure MarkCustomerReviewed() + begin + ReviewedCustomerCount += 1; + end; + + var + ReviewedCustomerCount: Integer; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.good.al b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.good.al new file mode 100644 index 0000000..c220732 --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.good.al @@ -0,0 +1,31 @@ +report 50105 "Customer Entry Review" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnAfterGetRecord() + var + EntryNo: Integer; + StopReview: Boolean; + begin + repeat + EntryNo += 1; + StopReview := EntryNo = 5; + until StopReview or (EntryNo = 10); + + MarkCustomerReviewed(); + end; + } + } + + local procedure MarkCustomerReviewed() + begin + ReviewedCustomerCount += 1; + end; + + var + ReviewedCustomerCount: Integer; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.md b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.md new file mode 100644 index 0000000..ec22ea4 --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: reporting +keywords: [report, currreport, break, loop, trigger, control-flow] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# CurrReport.Break ends the current trigger + +## Description + +`CurrReport.Break()` inside a report dataitem trigger does more than leave an AL loop. It terminates the current trigger and omits the current record from the dataset. The report runtime still invokes the remaining triggers for that record. Consequently, statements after the loop in the current trigger do not run, while later report triggers can still produce side effects. + +## Best Practice + +Use an explicit loop condition or the AL `break` statement when only the loop must end and the current trigger must continue. Use `CurrReport.Break()` only when ending the trigger and omitting the current record are both intended, and keep subsequent report triggers safe for that omitted record. + +See sample: [`currreport-break-ends-the-current-trigger.good.al`](currreport-break-ends-the-current-trigger.good.al). + +## Anti Pattern + +Call `CurrReport.Break()` inside a loop and rely on statements after the loop to finish processing the current record. Those statements are unreachable when the call executes, the record is omitted, and remaining report triggers still run. + +See sample: [`currreport-break-ends-the-current-trigger.bad.al`](currreport-break-ends-the-current-trigger.bad.al). + +## References + +`Report.Break()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-break-method \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.bad.al b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.bad.al new file mode 100644 index 0000000..262ca78 --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.bad.al @@ -0,0 +1,27 @@ +report 50101 "Update Customer Review" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnAfterGetRecord() + begin + "Last Date Modified" := Today(); + Modify(); + + if Blocked <> Blocked::" " then + CurrReport.Quit(); + end; + } + } + + trigger OnPostReport() + begin + Message(CompletedMsg); + end; + + var + CompletedMsg: Label 'Customer review completed.'; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.good.al b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.good.al new file mode 100644 index 0000000..f7a50c5 --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.good.al @@ -0,0 +1,28 @@ +report 50101 "Update Customer Review" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnAfterGetRecord() + begin + if Blocked <> Blocked::" " then + Error(BlockedCustomerErr, "No."); + + "Last Date Modified" := Today(); + Modify(); + end; + } + } + + trigger OnPostReport() + begin + Message(CompletedMsg); + end; + + var + BlockedCustomerErr: Label 'Customer %1 is blocked.', Comment = '%1 = customer number'; + CompletedMsg: Label 'Customer review completed.'; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.md b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.md new file mode 100644 index 0000000..287f625 --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: reporting +keywords: [report, currreport, quit, rollback, onpostreport, transaction, control-flow] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# CurrReport.Quit rolls back report changes and skips OnPostReport + +## Description + +`CurrReport.Quit()` aborts the report without committing database changes made during its execution. It also prevents `OnPostReport` from running. It is therefore not a normal early-return mechanism for a processing report that expects earlier writes or finalization in `OnPostReport` to survive. + +## Best Practice + +Use `CurrReport.Quit()` only when silently aborting the report, rolling back its database changes, and skipping `OnPostReport` are all intentional. When processing must stop with a failure, raise an error. When completed work and `OnPostReport` must be preserved, structure the dataitem control flow without `Quit()`. + +See sample: [`currreport-quit-rolls-back-and-skips-onpostreport.good.al`](currreport-quit-rolls-back-and-skips-onpostreport.good.al). + +## Anti Pattern + +Modify data and then call `CurrReport.Quit()` while relying on those writes or on `OnPostReport` finalization. The report exits without committing its changes and never invokes `OnPostReport`. + +See sample: [`currreport-quit-rolls-back-and-skips-onpostreport.bad.al`](currreport-quit-rolls-back-and-skips-onpostreport.bad.al). + +## References + +`Report.Quit()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-quit-method \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.bad.al b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.bad.al new file mode 100644 index 0000000..1debd99 --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.bad.al @@ -0,0 +1,26 @@ +report 50100 "Released Customer List" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnAfterGetRecord() + begin + if Blocked <> Blocked::" " then + CurrReport.Skip(); + + CountIncludedCustomer(); + end; + } + } + + local procedure CountIncludedCustomer() + begin + IncludedCustomerCount += 1; + end; + + var + IncludedCustomerCount: Integer; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.good.al b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.good.al new file mode 100644 index 0000000..f239a2b --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.good.al @@ -0,0 +1,28 @@ +report 50100 "Released Customer List" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnAfterGetRecord() + begin + if Blocked <> Blocked::" " then begin + CurrReport.Skip(); + exit; + end; + + CountIncludedCustomer(); + end; + } + } + + local procedure CountIncludedCustomer() + begin + IncludedCustomerCount += 1; + end; + + var + IncludedCustomerCount: Integer; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.md b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.md new file mode 100644 index 0000000..d2b4e34 --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: reporting +keywords: [report, currreport, skip, trigger, onaftergetrecord, control-flow] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# CurrReport.Skip omits the record but does not stop trigger code + +## Description + +`CurrReport.Skip()` omits the current record from the report dataset and continues processing with the next record. It does not terminate the current trigger, and the remaining triggers for the current record still run. Code placed after `Skip()` can therefore produce side effects for a record that never appears in the output. + +## Best Practice + +When no further code in the current trigger should run for a skipped record, call `CurrReport.Skip()` and then exit the trigger explicitly. Keep later record triggers safe for skipped records because the report runtime still invokes them. + +See sample: [`currreport-skip-does-not-stop-trigger-code.good.al`](currreport-skip-does-not-stop-trigger-code.good.al). + +## Anti Pattern + +Call `CurrReport.Skip()` and rely on it to bypass subsequent statements or later record triggers. The record is removed from the dataset, but those statements and triggers can still update state, write data, or perform expensive work. + +See sample: [`currreport-skip-does-not-stop-trigger-code.bad.al`](currreport-skip-does-not-stop-trigger-code.bad.al). + +## References + +`Report.Skip()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-skip-method \ No newline at end of file diff --git a/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.bad.al b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.bad.al new file mode 100644 index 0000000..945c0ae --- /dev/null +++ b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.bad.al @@ -0,0 +1,14 @@ +codeunit 50106 "Download Customer Reports" +{ + procedure DownloadReports(var Customer: Record Customer) + var + CustomerView: Record Customer; + begin + if Customer.FindSet() then + repeat + CustomerView := Customer; + CustomerView.SetRecFilter(); + Report.Run(Report::"Customer - List", false, false, CustomerView); + until Customer.Next() = 0; + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.good.al b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.good.al new file mode 100644 index 0000000..dea2e7f --- /dev/null +++ b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.good.al @@ -0,0 +1,37 @@ +codeunit 50106 "Download Customer Reports" +{ + procedure DownloadReports(var Customer: Record Customer) + var + CustomerView: Record Customer; + CustomerList: Report "Customer - List"; + DataCompression: Codeunit "Data Compression"; + ReportTempBlob: Codeunit "Temp Blob"; + ZipTempBlob: Codeunit "Temp Blob"; + ReportInStream: InStream; + ZipInStream: InStream; + ReportOutStream: OutStream; + ZipOutStream: OutStream; + ZipFileName: Text; + begin + DataCompression.CreateZipArchive(); + if Customer.FindSet() then + repeat + Clear(CustomerList); + Clear(ReportTempBlob); + CustomerView := Customer; + CustomerView.SetRecFilter(); + CustomerList.SetTableView(CustomerView); + ReportTempBlob.CreateOutStream(ReportOutStream); + CustomerList.SaveAs('', ReportFormat::Pdf, ReportOutStream); + ReportTempBlob.CreateInStream(ReportInStream); + DataCompression.AddEntry(ReportInStream, Customer."No." + '.pdf'); + until Customer.Next() = 0; + + ZipTempBlob.CreateOutStream(ZipOutStream); + DataCompression.SaveZipArchive(ZipOutStream); + DataCompression.CloseZipArchive(); + ZipTempBlob.CreateInStream(ZipInStream); + ZipFileName := 'CustomerReports.zip'; + DownloadFromStream(ZipInStream, '', '', '*.zip', ZipFileName); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.md b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.md new file mode 100644 index 0000000..8aede8b --- /dev/null +++ b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: reporting +keywords: [report, run, saveas, downloadfromstream, web-client, loop, zip, data-compression] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Report output in a loop needs one client download + +## Description + +The Business Central Web client can deliver only one file per request. When AL generates or downloads a report file repeatedly in the same request, only the last file is delivered to the browser. Earlier report output is silently unavailable to the user even though every iteration ran. + +## Best Practice + +Generate each report into a stream, add the streams to one archive, and call `DownloadFromStream` once after the loop. A direct report run or download inside a loop is valid only when the execution context does not use the Web client or the loop is guaranteed to execute at most once. + +See sample: [`report-output-in-a-loop-needs-one-client-download.good.al`](report-output-in-a-loop-needs-one-client-download.good.al). + +## Anti Pattern + +Call `Report.Run`, `Report.RunModal`, or `DownloadFromStream` repeatedly in a loop initiated by one Web client action and expect every generated file to reach the browser. The client receives only the last download. + +See sample: [`report-output-in-a-loop-needs-one-client-download.bad.al`](report-output-in-a-loop-needs-one-client-download.bad.al). + +## References + +`File.DownloadFromStream` method — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/file/file-downloadfromstream-method + +`Data Compression` codeunit — https://learn.microsoft.com/dynamics365/business-central/application/system-application/codeunit/system.io.data-compression \ No newline at end of file diff --git a/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.bad.al b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.bad.al new file mode 100644 index 0000000..7a25a12 --- /dev/null +++ b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.bad.al @@ -0,0 +1,30 @@ +report 50103 "Base Customer Export" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnPreDataItem() + begin + SetRange(Blocked, Blocked::" "); + SetRange("Country/Region Code"); + end; + } + } +} + +reportextension 50104 "Local Customer Export" extends "Base Customer Export" +{ + dataset + { + modify(Customer) + { + trigger OnBeforePreDataItem() + begin + SetFilter("Country/Region Code", '<>%1', ''); + end; + } + } +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.good.al b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.good.al new file mode 100644 index 0000000..52c3ebe --- /dev/null +++ b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.good.al @@ -0,0 +1,30 @@ +report 50103 "Base Customer Export" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnPreDataItem() + begin + SetRange(Blocked, Blocked::" "); + SetRange("Country/Region Code"); + end; + } + } +} + +reportextension 50104 "Local Customer Export" extends "Base Customer Export" +{ + dataset + { + modify(Customer) + { + trigger OnAfterPreDataItem() + begin + SetFilter("Country/Region Code", '<>%1', ''); + end; + } + } +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.md b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.md new file mode 100644 index 0000000..c149748 --- /dev/null +++ b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.md @@ -0,0 +1,32 @@ +--- +bc-version: [19..] +domain: reporting +keywords: [reportextension, report, dataitem, trigger-order, onbeforepredataitem, onafterpredataitem, onbeforeaftergetrecord, onafteraftergetrecord] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Choose ReportExtension dataitem triggers by their order around the base trigger + +## Description + +ReportExtension dataitem triggers run at defined points around the corresponding base-report trigger. `OnBeforePreDataItem` and `OnBeforeAfterGetRecord` run before the base trigger; `OnAfterPreDataItem` and `OnAfterAfterGetRecord` run after it. A filter or calculated value can be overwritten when an extension uses a before-trigger even though its result must be final after base processing. + +## Best Practice + +Choose the before or after trigger from the required ordering relative to base behavior. Use an after-trigger when the extension must observe or refine the final view or value produced by the base trigger. A before-trigger is valid when the base report must consume the extension's state. + +See sample: [`reportextension-dataitem-trigger-order-is-explicit.good.al`](reportextension-dataitem-trigger-order-is-explicit.good.al). + +## Anti Pattern + +Place extension logic in a before-trigger while relying on its filter or value to survive a base trigger that can replace it. Do not report a before-trigger merely because an after-trigger exists; the defect requires visible base behavior or another reliable source showing that ordering changes the result. + +See sample: [`reportextension-dataitem-trigger-order-is-explicit.bad.al`](reportextension-dataitem-trigger-order-is-explicit.bad.al). + +## References + +`OnBeforePreDataItem` report-extension trigger — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/reportextensiondatasetmodify/devenv-onbeforepredataitem-reportextensiondatasetmodify-trigger + +`OnAfterPreDataItem` report-extension trigger — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/reportextensiondatasetmodify/devenv-onafterpredataitem-reportextensiondatasetmodify-trigger \ No newline at end of file diff --git a/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.bad.al b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.bad.al new file mode 100644 index 0000000..d932f91 --- /dev/null +++ b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.bad.al @@ -0,0 +1,31 @@ +report 50110 "Customer Export" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + } + } + + trigger OnPreReport() + var + ExportSetup: Record "Customer Export Setup"; + begin + ExportSetup.Get(); + ExportSetup.TestField("Export Date"); + end; +} + +reportextension 50111 "Customer Export Extension" extends "Customer Export" +{ + trigger OnPreReport() + var + ExportSetup: Record "Customer Export Setup"; + begin + ExportSetup.Get(); + ExportSetup.Validate("Export Date", Today()); + ExportSetup.Modify(true); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.good.al b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.good.al new file mode 100644 index 0000000..293784b --- /dev/null +++ b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.good.al @@ -0,0 +1,37 @@ +report 50110 "Customer Export" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + } + } + + trigger OnPreReport() + var + ExportDate: Date; + begin + OnBeforeResolveExportDate(ExportDate); + if ExportDate = 0D then + Error(ExportDateRequiredErr); + end; + + [IntegrationEvent(false, false)] + local procedure OnBeforeResolveExportDate(var ExportDate: Date) + begin + end; + + var + ExportDateRequiredErr: Label 'An export date is required.'; +} + +codeunit 50111 "Customer Export Extension" +{ + [EventSubscriber(ObjectType::Report, Report::"Customer Export", 'OnBeforeResolveExportDate', '', false, false)] + local procedure SetExportDate(var ExportDate: Date) + begin + ExportDate := Today(); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.md b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.md new file mode 100644 index 0000000..414752a --- /dev/null +++ b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.md @@ -0,0 +1,30 @@ +--- +bc-version: [18..] +domain: reporting +keywords: [reportextension, report, trigger-order, onprereport, onpostreport, base-report, integration-event] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ReportExtension report triggers run after base report triggers + +## Description + +`OnPreReport` and `OnPostReport` on a ReportExtension run after the corresponding triggers on the base report. An extension `OnPreReport` cannot prepare state that the base `OnPreReport` must consume, and an extension `OnPostReport` cannot affect finalization that the base `OnPostReport` has already completed. + +## Best Practice + +Use a base-report event at the required execution point when extension logic must run before or within a base trigger. Use ReportExtension `OnPreReport` and `OnPostReport` only for work that is correct after the corresponding base trigger. Report a violation only when the base trigger and extension dependency are both visible or otherwise established. + +See sample: [`reportextension-report-triggers-run-after-base-triggers.good.al`](reportextension-report-triggers-run-after-base-triggers.good.al). + +## Anti Pattern + +Initialize data in a ReportExtension `OnPreReport` and rely on the base report's `OnPreReport` to consume it, or perform extension `OnPostReport` work that the base `OnPostReport` needed beforehand. The base trigger has already run. + +See sample: [`reportextension-report-triggers-run-after-base-triggers.bad.al`](reportextension-report-triggers-run-after-base-triggers.bad.al). + +## References + +Report extension object — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-report-ext-object \ No newline at end of file diff --git a/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.bad.al b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.bad.al new file mode 100644 index 0000000..844c542 --- /dev/null +++ b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.bad.al @@ -0,0 +1,26 @@ +report 50107 "Selected Sales Orders" +{ + ProcessingOnly = true; + + dataset + { + dataitem(SalesHeader; "Sales Header") + { + DataItemTableView = where("Document Type" = const(Order), Status = const(Open)); + } + } +} + +codeunit 50108 "Run Selected Sales Orders" +{ + procedure RunReleasedOrders() + var + SalesHeader: Record "Sales Header"; + SelectedSalesOrders: Report "Selected Sales Orders"; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + SalesHeader.SetRange(Status, SalesHeader.Status::Released); + SelectedSalesOrders.SetTableView(SalesHeader); + SelectedSalesOrders.RunModal(); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.good.al b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.good.al new file mode 100644 index 0000000..5dc4f2e --- /dev/null +++ b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.good.al @@ -0,0 +1,26 @@ +report 50107 "Selected Sales Orders" +{ + ProcessingOnly = true; + + dataset + { + dataitem(SalesHeader; "Sales Header") + { + DataItemTableView = where("Document Type" = const(Order)); + } + } +} + +codeunit 50108 "Run Selected Sales Orders" +{ + procedure RunReleasedOrders() + var + SalesHeader: Record "Sales Header"; + SelectedSalesOrders: Report "Selected Sales Orders"; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + SalesHeader.SetRange(Status, SalesHeader.Status::Released); + SelectedSalesOrders.SetTableView(SalesHeader); + SelectedSalesOrders.RunModal(); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.md b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.md new file mode 100644 index 0000000..2dfc691 --- /dev/null +++ b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: reporting +keywords: [report, settableview, dataitemtableview, filter, view, narrowing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# SetTableView cannot broaden DataItemTableView + +## Description + +`Report.SetTableView()` applies the supplied record view by narrowing the view already defined by the report dataitem's `DataItemTableView`. It cannot remove or broaden a static dataitem filter. A caller that requests records excluded by `DataItemTableView` therefore produces an empty dataset rather than overriding the report filter. + +## Best Practice + +Keep only invariant restrictions in `DataItemTableView`. When callers must select among values, leave that dimension open in the static view and pass the required filter through `SetTableView`. Review this as a defect only when the report definition and caller together show a contradictory filter. + +See sample: [`settableview-cannot-broaden-dataitemtableview.good.al`](settableview-cannot-broaden-dataitemtableview.good.al). + +## Anti Pattern + +Define a static filter in `DataItemTableView` and call `SetTableView` with a mutually exclusive filter while expecting the runtime view to replace the static one. The filters are intersected and no records are selected. + +See sample: [`settableview-cannot-broaden-dataitemtableview.bad.al`](settableview-cannot-broaden-dataitemtableview.bad.al). + +## References + +`Report.SetTableView()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-settableview-method \ No newline at end of file diff --git a/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.bad.al b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.bad.al new file mode 100644 index 0000000..feccfb6 --- /dev/null +++ b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.bad.al @@ -0,0 +1,17 @@ +codeunit 50109 "Export Customer Report" +{ + procedure ExportReport() + var + TempBlob: Codeunit "Temp Blob"; + ReportOutStream: OutStream; + RequestPageParameters: Text; + begin + RequestPageParameters := Report.RunRequestPage(Report::"Customer - List"); + TempBlob.CreateOutStream(ReportOutStream); + Report.SaveAs( + Report::"Customer - List", + RequestPageParameters, + ReportFormat::Pdf, + ReportOutStream); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.good.al b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.good.al new file mode 100644 index 0000000..d706a2f --- /dev/null +++ b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.good.al @@ -0,0 +1,20 @@ +codeunit 50109 "Export Customer Report" +{ + procedure ExportReport() + var + TempBlob: Codeunit "Temp Blob"; + ReportOutStream: OutStream; + RequestPageParameters: Text; + begin + RequestPageParameters := Report.RunRequestPage(Report::"Customer - List"); + if RequestPageParameters = '' then + exit; + + TempBlob.CreateOutStream(ReportOutStream); + Report.SaveAs( + Report::"Customer - List", + RequestPageParameters, + ReportFormat::Pdf, + ReportOutStream); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.md b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.md new file mode 100644 index 0000000..0cc0e71 --- /dev/null +++ b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: reporting +keywords: [report, runrequestpage, cancel, parameters, saveas, execute, print] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Stop when RunRequestPage returns empty parameters + +## Description + +`Report.RunRequestPage()` returns an empty string when the user chooses **Cancel**. Passing that value to `Report.Execute`, `Report.Print`, or `Report.SaveAs` ignores the cancellation and can run the report with default parameters instead. + +## Best Practice + +Test the returned parameter string immediately after `RunRequestPage()` and exit when it is empty. Pass the value to `Execute`, `Print`, or `SaveAs` only after the user has confirmed the request page. + +See sample: [`stop-when-runrequestpage-returns-empty-parameters.good.al`](stop-when-runrequestpage-returns-empty-parameters.good.al). + +## Anti Pattern + +Call `RunRequestPage()` and unconditionally pass its return value to a report execution method. Choosing **Cancel** can still execute, print, or save the report. + +See sample: [`stop-when-runrequestpage-returns-empty-parameters.bad.al`](stop-when-runrequestpage-returns-empty-parameters.bad.al). + +## References + +`Report.RunRequestPage()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/report-runrequestpage-method \ No newline at end of file diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 9b5f739..dc1f1db 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -25,6 +25,7 @@ sub-skills: - microsoft/skills/review/al-testing-review.md - microsoft/skills/review/al-data-modeling-review.md - microsoft/skills/review/al-query-review.md + - microsoft/skills/review/al-reporting-review.md - microsoft/skills/review/al-appsource-review.md - microsoft/skills/review/al-telemetry-review.md --- diff --git a/microsoft/skills/review/al-reporting-review.md b/microsoft/skills/review/al-reporting-review.md new file mode 100644 index 0000000..52f8f53 --- /dev/null +++ b/microsoft/skills/review/al-reporting-review.md @@ -0,0 +1,63 @@ +--- +kind: action-skill +id: al-reporting-review +version: 1 +title: AL reporting review +description: Reviews AL Report and ReportExtension code against BCQuality reporting guidance. +inputs: [pr-diff, file-path, folder-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL reporting review + +Reviews AL source changes against the `reporting` knowledge domain in BCQuality. This is a leaf action skill composed by `al-code-review`. + +## Source + +Use READ's **Bounded retrieval for review skills** workflow with `-Domain reporting`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. + +## Relevance + +Apply READ's frontmatter matching rules against the task context. Use the target version from `app.json` when available and `[al]` for technologies. Retain conditionally applicable files only when configured; cap resulting confidence at `medium` and name every unknown dimension in the finding message. + +Return `not-applicable` when the input contains no Report or ReportExtension declaration and no Report variable or method call. + +## Worklist + +Match relevant entries against changed `report` and `reportextension` objects, variables typed as `Report`, and the tokens `CurrReport`, `Skip`, `Break`, `Quit`, `Run`, `RunModal`, `RunRequestPage`, `Execute`, `Print`, `SaveAs`, `DownloadFromStream`, `Data Compression`, `SetTableView`, `DataItemTableView`, `OnPreReport`, `OnPostReport`, `OnPreDataItem`, `OnAfterGetRecord`, and report-extension dataset triggers. + +Apply this targeted check even when token overlap would rank the article below the worklist cutoff: + +- The same Report variable has two logically independent `RunModal()` executions without `Clear` before the second configuration — `clear-report-variable-before-independent-runmodal`. +- `CurrReport.Break()` is used inside an explicit loop while reachable statements after the loop are expected to finish the current trigger — `currreport-break-ends-the-current-trigger`. +- `CurrReport.Quit()` follows database writes or the report relies on `OnPostReport` finalization — `currreport-quit-rolls-back-and-skips-onpostreport`. +- `CurrReport.Skip()` is followed by reachable code in the same trigger, or later record triggers contain work that is unsafe for skipped records — `currreport-skip-does-not-stop-trigger-code`. +- A loop reachable from one Web client action calls `Report.Run`, `Report.RunModal`, or `DownloadFromStream` more than once instead of producing one archive download — `report-output-in-a-loop-needs-one-client-download`. Do not select this article when the context is non-Web or the loop is provably single-iteration. +- A ReportExtension before-trigger establishes a filter or value that visible base-trigger code subsequently replaces — `reportextension-dataitem-trigger-order-is-explicit`. Do not select this article from a before-trigger alone. +- A ReportExtension `OnPreReport` prepares state consumed by the base `OnPreReport`, or its `OnPostReport` prepares state already consumed by the base `OnPostReport` — `reportextension-report-triggers-run-after-base-triggers`. Require visible base behavior or equivalent established evidence. +- A report's `DataItemTableView` and a caller's `SetTableView` apply mutually exclusive filters to the same field — `settableview-cannot-broaden-dataitemtableview`. Require both views or equivalent direct evidence; `SetTableView` alone is not a finding. +- The value returned by `Report.RunRequestPage()` reaches `Report.Execute`, `Report.Print`, or `Report.SaveAs` without an empty-string cancellation check — `stop-when-runrequestpage-returns-empty-parameters`. + +Resolve layer conflicts per READ. When no reporting knowledge exists, emit `no-knowledge`; when knowledge exists but no article matches the changed report code, emit `completed` with no findings. + +## Action + +Evaluate every worklist article against the diff's report control flow and surrounding triggers. + +- Emit `major` for an unambiguous Anti Pattern that causes incorrect output, persisted side effects, or lost work. +- Emit `minor` when code contradicts a Best Practice but the effect depends on unseen report or caller context. +- Do not emit applicability-only information. A reporting article produces a finding only when changed code violates its normative guidance. + +Set confidence to `high` for locally visible control flow and `medium` when base-report behavior, callers, or missing context affect the conclusion. Domain-scoped agent findings follow DO's precision bar and remain capped at `minor`/`medium`. + +Provide `suggested-code` only when the replacement is complete, local, and unambiguous. Otherwise set `suggested-code-omission-reason`. + +Outcome selection follows DO: `completed`, `no-knowledge`, `not-applicable`, `partial`, or `failed`. + +## Output + +Output conforms to the DO findings-report contract. Every finding this skill emits MUST set `findings[].domain` to `"Reporting"`. \ No newline at end of file From d24dc7b14b39624e8b2160dbca73d66140106e4c Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 15 Sep 2026 10:45:35 +0200 Subject: [PATCH 77/86] Fix reporting skill index expectation (#185) Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/Test-SkillIndex.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/scripts/Test-SkillIndex.ps1 b/.github/scripts/Test-SkillIndex.ps1 index 839042f..ed07204 100644 --- a/.github/scripts/Test-SkillIndex.ps1 +++ b/.github/scripts/Test-SkillIndex.ps1 @@ -91,6 +91,7 @@ try { 'microsoft/skills/review/al-testing-review.md', 'microsoft/skills/review/al-data-modeling-review.md', 'microsoft/skills/review/al-query-review.md', + 'microsoft/skills/review/al-reporting-review.md', 'microsoft/skills/review/al-appsource-review.md', 'microsoft/skills/review/al-telemetry-review.md' ) @@ -99,7 +100,7 @@ try { throw "Expected exactly one al-code-review record, found $($review.Count)." } if ((@($review[0].subSkills) -join "`n") -cne ($expectedLeaves -join "`n")) { - throw 'al-code-review subSkills did not preserve the declared 16-leaf order.' + throw 'al-code-review subSkills did not preserve the declared 17-leaf order.' } foreach ($leafPath in $expectedLeaves) { $leaf = @($skills | Where-Object path -ceq $leafPath) @@ -237,4 +238,4 @@ finally { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } -Write-Output 'Skill-index check PASSED: deterministic, schema-valid, and all 16 review leaves preserved in order.' +Write-Output 'Skill-index check PASSED: deterministic, schema-valid, and all 17 review leaves preserved in order.' From b7617fb48a01c9f41116e37edaede3daed424236 Mon Sep 17 00:00:00 2001 From: waldo Date: Tue, 15 Sep 2026 12:34:28 +0200 Subject: [PATCH 78/86] knowledge(events): ChangeCompany leaves triggers and trigger-event subscribers running in the calling company (#152) * knowledge(events): ChangeCompany leaves triggers and trigger-event subscribers running in the calling company ChangeCompany redirects only the data access of a record variable; Learn states that triggers still run in the current company. The database trigger events are raised on every database operation and only pass RunTrigger to the subscriber, so Insert(false) after ChangeCompany still runs every subscriber in the calling company. Generated code either assumes the record 'becomes' a target-company record, or switches RunTrigger off and hand-copies the trigger logic, leaving the subscribers writing to the wrong company; none of the tested runs reached StartSession with the company parameter. Co-Authored-By: Claude Fable 5.1 * knowledge(events): qualify StartSession async semantics and fix concurrency-unsafe sample key Addresses PR #152 review: StartSession is a fire-and-forget background session (Ok reports only whether it started, not whether the codeunit succeeded, and errors inside it do not propagate), so the Best Practice now scopes the recommendation and calls out the durable status/error channel a synchronous-success write needs. The good sample's FindLast()+1 entry-number pattern raced under concurrent background sessions; switched to AutoIncrement, which the platform guarantees is unique across concurrent transactions. Co-Authored-By: Claude Sonnet 5 * knowledge(events): serialize the setup-counter increment; promote article and wire the review skill PR #152 round 3 (JesperSchulz): - The good sample's OnAfterInsertEvent subscriber still raced on the shared "Transfer Setup Good" singleton (Get/increment/Modify); AutoIncrement only protected the request key. Added TransferSetup.LockTable() before Get() to serialize concurrent background sessions. - Promoted changecompany-runs-triggers-in-the-calling-company from community/knowledge/events/ to microsoft/knowledge/events/, and wired ChangeCompany/StartSession/RunTrigger tokens plus a targeted detection cue into microsoft/skills/review/al-events-review.md so a diff containing the anti-pattern reliably worklists this article, preserving the documented RunTrigger=false hand-off exception. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: waldo1001 <12088142+waldo1001@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- ...uns-triggers-in-the-calling-company.bad.al | 91 +++++++++++++++++++ ...ns-triggers-in-the-calling-company.good.al | 84 +++++++++++++++++ ...ny-runs-triggers-in-the-calling-company.md | 42 +++++++++ microsoft/skills/review/al-events-review.md | 3 +- 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.bad.al create mode 100644 microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.good.al create mode 100644 microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.md diff --git a/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.bad.al b/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.bad.al new file mode 100644 index 0000000..7b8e7d5 --- /dev/null +++ b/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.bad.al @@ -0,0 +1,91 @@ +codeunit 50100 "Transfer Request Bad" +{ + // Self-contained demonstration of the anti pattern. Not derived from base-app source. + procedure RequestFromCompany(TargetCompany: Text[30]; ItemNo: Code[20]; Quantity: Decimal) + var + TransferRequest: Record "Transfer Request Bad"; + TransferSetup: Record "Transfer Setup Bad"; + begin + TransferRequest.ChangeCompany(TargetCompany); + TransferSetup.ChangeCompany(TargetCompany); + TransferSetup.Get(); + + TransferRequest.Init(); + TransferRequest."Entry No." := NextEntryNo(TargetCompany); + TransferRequest."Item No." := ItemNo; + TransferRequest.Quantity := Quantity; + // OnInsert is skipped below, so the default is copied by hand from the target company's setup. + TransferRequest."Location Code" := TransferSetup."Default Location Code"; + // The OnAfterInsertEvent subscriber still fires, in the calling company, and grows the caller's counter. + TransferRequest.Insert(false); + + TransferSetup."Open Requests" += 1; + TransferSetup.Modify(); + end; + + local procedure NextEntryNo(TargetCompany: Text[30]): Integer + var + LastRequest: Record "Transfer Request Bad"; + begin + LastRequest.ChangeCompany(TargetCompany); + if LastRequest.FindLast() then + exit(LastRequest."Entry No." + 1); + exit(1); + end; +} + +table 50100 "Transfer Request Bad" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Item No."; Code[20]) { } + field(3; Quantity; Decimal) { } + field(4; "Location Code"; Code[10]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } + + trigger OnInsert() + var + TransferSetup: Record "Transfer Setup Bad"; + begin + TransferSetup.Get(); + "Location Code" := TransferSetup."Default Location Code"; + end; +} + +table 50101 "Transfer Setup Bad" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Primary Key"; Code[10]) { } + field(2; "Default Location Code"; Code[10]) { } + field(3; "Open Requests"; Integer) { } + } + + keys + { + key(PK; "Primary Key") { Clustered = true; } + } +} + +codeunit 50101 "Transfer Request Count Bad" +{ + [EventSubscriber(ObjectType::Table, Database::"Transfer Request Bad", OnAfterInsertEvent, '', false, false)] + local procedure CountOpenRequest(var Rec: Record "Transfer Request Bad"; RunTrigger: Boolean) + var + TransferSetup: Record "Transfer Setup Bad"; + begin + TransferSetup.Get(); + TransferSetup."Open Requests" += 1; + TransferSetup.Modify(); + end; +} diff --git a/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.good.al b/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.good.al new file mode 100644 index 0000000..dd92d9d --- /dev/null +++ b/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.good.al @@ -0,0 +1,84 @@ +codeunit 50100 "Transfer Request Good" +{ + // Self-contained demonstration of the best practice. Not derived from base-app source. + procedure RequestFromCompany(TargetCompany: Text[30]; ItemNo: Code[20]; Quantity: Decimal) + var + TransferRequest: Record "Transfer Request Good"; + SessionId: Integer; + begin + TransferRequest.Init(); + TransferRequest."Item No." := ItemNo; + TransferRequest.Quantity := Quantity; + // The insert runs inside TargetCompany, so OnInsert and the subscriber read that company's setup. + StartSession(SessionId, Codeunit::"Transfer Request Create Good", TargetCompany, TransferRequest); + end; +} + +codeunit 50102 "Transfer Request Create Good" +{ + TableNo = "Transfer Request Good"; + + trigger OnRun() + begin + // "Entry No." is AutoIncrement, so concurrent background sessions in TargetCompany never race on the same value. + Rec.Insert(true); + end; +} + +table 50100 "Transfer Request Good" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) { AutoIncrement = true; } + field(2; "Item No."; Code[20]) { } + field(3; Quantity; Decimal) { } + field(4; "Location Code"; Code[10]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } + + trigger OnInsert() + var + TransferSetup: Record "Transfer Setup Good"; + begin + TransferSetup.Get(); + "Location Code" := TransferSetup."Default Location Code"; + end; +} + +table 50101 "Transfer Setup Good" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Primary Key"; Code[10]) { } + field(2; "Default Location Code"; Code[10]) { } + field(3; "Open Requests"; Integer) { } + } + + keys + { + key(PK; "Primary Key") { Clustered = true; } + } +} + +codeunit 50101 "Transfer Request Count Good" +{ + [EventSubscriber(ObjectType::Table, Database::"Transfer Request Good", OnAfterInsertEvent, '', false, false)] + local procedure CountOpenRequest(var Rec: Record "Transfer Request Good"; RunTrigger: Boolean) + var + TransferSetup: Record "Transfer Setup Good"; + begin + // Serializes the read-modify-write so concurrent background sessions don't lose an increment. + TransferSetup.LockTable(); + TransferSetup.Get(); + TransferSetup."Open Requests" += 1; + TransferSetup.Modify(); + end; +} diff --git a/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.md b/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.md new file mode 100644 index 0000000..4a524f4 --- /dev/null +++ b/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.md @@ -0,0 +1,42 @@ +--- +bc-version: [all] +domain: events +keywords: [changecompany, cross-company, runtrigger, trigger-event, subscriber, onafterinsertevent, insert, startsession, multi-company] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ChangeCompany leaves triggers and trigger-event subscribers running in the calling company + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`ChangeCompany` redirects the data access of one record variable to another company's table. Execution context does not move with it: Microsoft Learn states that triggers still run in the current company, not in the company passed to `ChangeCompany`. Code that knows this usually reaches for `Insert(false)` and copies the trigger's work by hand from the target company's setup. That closes only half of the gap. The runtime raises the database trigger events (`OnBeforeInsertEvent`, `OnAfterInsertEvent`, and their modify, delete, and rename counterparts) on every database operation and only passes the `RunTrigger` flag to the subscriber, so every subscriber that does not exit on `RunTrigger = false` still runs, in the calling company, against the calling company's setup, number series, and companion tables. The row lands in the target company, the side effects land in the caller, and nothing reports an error. The per-row cost of the call is a separate concern, see `changecompany-in-loop-drops-caches`. + +## Best Practice + +Use `ChangeCompany` to read. Access rights in the target company are still enforced, so reads are safe. When the goal is business data in another company, run the code in that company: `StartSession` takes a company name and runs a codeunit there, so triggers, validation, and subscribers all execute with the target company as their context. `StartSession` is a background session, not a synchronous call: the `Ok` return value reports only whether the session started, not whether the codeunit's work inside it succeeded, the caller's transaction does not extend into it, and an error raised there does not come back to the caller — it has to be logged or telemetered from inside that session. Reach for `StartSession` only for work the caller does not need to confirm before it continues; a write whose success the caller must know synchronously needs a durable status or error channel (a field the caller polls, a job queue with retry) rather than a bare `StartSession` call. Learn notes that a background session costs as much as a user session to start, so batch the work rather than starting one session per row, or let the target company process a hand-off row on its own schedule. A direct cross-company write is acceptable only as such a hand-off into a table the writing extension owns, whose triggers do not read company data and whose trigger-event subscribers exit when `RunTrigger` is false, using `Insert(false)`, `Modify(false)`, or `Delete(false)`, and never `Validate`. + +See sample: [`changecompany-runs-triggers-in-the-calling-company.good.al`](changecompany-runs-triggers-in-the-calling-company.good.al). + +## Anti Pattern + +An `Insert`, `Modify`, `Delete`, or `Validate` on a record variable after `ChangeCompany()`, on a table whose triggers or trigger-event subscribers read setup, consume a number series, or write companion rows. With `RunTrigger = true` the trigger code fills the row from the caller's setup. With `RunTrigger = false` the trigger code is skipped, but the subscribers still fire in the caller, so a counter, log, or companion row maintained by a subscriber is written in the wrong company, and a caller that also updates the target by hand counts twice. + +Detection signal: a record variable that has had `ChangeCompany` called on it with a company name and is later used with `Insert`, `Modify`, `Delete`, or `Validate`, where the table is not owned by the extension, or has triggers that read company data, or has trigger-event subscribers that do not exit on `RunTrigger = false`. Do not flag reads after `ChangeCompany`; writes with `RunTrigger = false` into an owned table whose triggers do not read company data and whose subscribers exit on `RunTrigger = false`; or `ChangeCompany()` without an argument, which points the variable back at the current company. + +See sample: [`changecompany-runs-triggers-in-the-calling-company.bad.al`](changecompany-runs-triggers-in-the-calling-company.bad.al). + +## See also + +- Record.ChangeCompany method, Remarks — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-changecompany-method +- Record.Insert(Boolean) method, RunTrigger — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-insert-boolean-method +- Record.Delete method, RunTrigger defaults to false — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-delete-method +- OnInsert (Table) trigger, Remarks — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/triggers-auto/table/devenv-oninsert-table-trigger +- Event types, Database trigger events and order of event execution — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-event-types +- OnAfterInsertEvent trigger event, RunTrigger parameter — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/triggers-auto/events/table/devenv-onafterinsertevent-table-trigger +- Session.StartSession method, Company parameter, Remarks (background session, no UI), and Return Value (`Ok` reports whether the session started, not whether the codeunit's work succeeded) — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/session/session-startsession-integer-integer-string-table-method +- AL error handling, error handling strategies: an error inside a rolled-back transaction is logged from a background session or telemetry, it does not return to the caller — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-al-error-handling +- AutoIncrement property, Remarks: "if several transactions are performed at the same time, they will each be assigned a different number" — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-autoincrement-property diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index 68bcdb0..b257d3d 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-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 codeunits that publish events or host event subscribers, posting/release/validation routines that should expose extension points, and test codeunits that bind subscribers. - The changed procedures and triggers, weighted toward event publisher methods, methods carrying the `[EventSubscriber(...)]` attribute, routines that raise `OnBefore`/`OnAfter` events, and any procedure that calls `BindSubscription`/`UnbindSubscription`. -- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `InternalEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `GlobalVarAccess`, `Isolated`, `local`, `internal`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`). +- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `InternalEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `GlobalVarAccess`, `Isolated`, `local`, `internal`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`, `ChangeCompany`, `StartSession`, `RunTrigger`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. @@ -65,6 +65,7 @@ The following targeted checks map diff signals to specific `events` articles. Tr - A `RecordRef` event parameter, or a passed-through `xRec`, where a concrete typed record fits — `avoid-loosely-typed-event-parameters`. - A `var IsHandled` added to a pre-existing event rather than introduced through a new `OnBefore` publisher — `do-not-add-ishandled-to-an-existing-event`. - An `if IsHandled then exit;` whose skipped body performs posting, ledger-entry creation, number-series consumption, or integrity/permission validation — `do-not-bypass-critical-operations-with-ishandled`. +- A record variable that had `ChangeCompany()` called on it and is later used with `Insert`, `Modify`, `Delete`, or `Validate`, where the table is not owned by the extension, has triggers that read company data, or has trigger-event subscribers that do not exit on `RunTrigger = false` — `changecompany-runs-triggers-in-the-calling-company`. Do not match a read-only use after `ChangeCompany`, a write with `RunTrigger = false` into an extension-owned table whose triggers do not read company data and whose trigger-event subscribers exit on `RunTrigger = false`, or the parameterless `ChangeCompany()` reset. ## Action From 861f53dd97fcc25cc797e79902884c7bbb612178 Mon Sep 17 00:00:00 2001 From: Stefano Demiliani <33155438+demiliani@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:51:15 +0200 Subject: [PATCH 79/86] Add query filter semantics guidance (#186) --- evaluation/review-fixtures.json | 8 ++++ ...er-cannot-be-overwritten-at-runtime.bad.al | 39 +++++++++++++++++++ ...r-cannot-be-overwritten-at-runtime.good.al | 38 ++++++++++++++++++ ...filter-cannot-be-overwritten-at-runtime.md | 30 ++++++++++++++ ...ilter-overwrites-query-columnfilter.bad.al | 37 ++++++++++++++++++ ...lter-overwrites-query-columnfilter.good.al | 38 ++++++++++++++++++ ...setfilter-overwrites-query-columnfilter.md | 30 ++++++++++++++ microsoft/skills/review/al-query-review.md | 8 ++-- 8 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al create mode 100644 microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al create mode 100644 microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.md create mode 100644 microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.bad.al create mode 100644 microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.good.al create mode 100644 microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.md diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 3eeed68..90c42c6 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -30,6 +30,14 @@ "privacy": { "article": "no-pii-in-telemetry-message-string" }, + "query": { + "articles": [ + "dataitemtablefilter-cannot-be-overwritten-at-runtime", + "reopening-query-resets-cursor-but-keeps-filters", + "set-query-filters-before-open", + "setfilter-overwrites-query-columnfilter" + ] + }, "reporting": { "articles": [ "clear-report-variable-before-independent-runmodal", diff --git a/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al new file mode 100644 index 0000000..67012eb --- /dev/null +++ b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al @@ -0,0 +1,39 @@ +query 50428 "Static Query Filter Bad" +{ + QueryType = Normal; + + elements + { + dataitem(SalesHeader; "Sales Header") + { + DataItemTableFilter = Status = const(Open); + + column(DocumentNo; "No.") + { + } + filter(StatusFilter; Status) + { + } + } + } +} + +codeunit 50429 "Static Query Filter Bad" +{ + procedure ReadReleasedOrders() + var + SalesHeader: Record "Sales Header"; + SalesHeaderQuery: Query "Static Query Filter Bad"; + begin + // This is combined with Status = Open and returns no rows. + SalesHeaderQuery.SetRange(StatusFilter, SalesHeader.Status::Released); + SalesHeaderQuery.Open(); + while SalesHeaderQuery.Read() do + ProcessOrder(SalesHeaderQuery.DocumentNo); + SalesHeaderQuery.Close(); + end; + + local procedure ProcessOrder(DocumentNo: Code[20]) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al new file mode 100644 index 0000000..d095330 --- /dev/null +++ b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al @@ -0,0 +1,38 @@ +query 50430 "Static Query Filter Good" +{ + QueryType = Normal; + + elements + { + dataitem(SalesHeader; "Sales Header") + { + DataItemTableFilter = "Document Type" = const(Order); + + column(DocumentNo; "No.") + { + } + filter(StatusFilter; Status) + { + } + } + } +} + +codeunit 50431 "Static Query Filter Good" +{ + procedure ReadReleasedOrders() + var + SalesHeader: Record "Sales Header"; + SalesHeaderQuery: Query "Static Query Filter Good"; + begin + SalesHeaderQuery.SetRange(StatusFilter, SalesHeader.Status::Released); + SalesHeaderQuery.Open(); + while SalesHeaderQuery.Read() do + ProcessOrder(SalesHeaderQuery.DocumentNo); + SalesHeaderQuery.Close(); + end; + + local procedure ProcessOrder(DocumentNo: Code[20]) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.md b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.md new file mode 100644 index 0000000..6a96db1 --- /dev/null +++ b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: query +keywords: [query, dataitemtablefilter, setfilter, setrange, static-filter, filter-precedence] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# DataItemTableFilter cannot be overwritten at runtime + +## Description + +`DataItemTableFilter` defines a static filter on a Query dataitem. A runtime `SetFilter` or `SetRange` on the same source field does not replace that filter. The static and runtime filters are combined with AND, so contradictory values produce an empty dataset instead of broadening or replacing the query definition. + +## Best Practice + +Keep only invariant restrictions in `DataItemTableFilter`. Expose caller-selectable fields through a column or filter row and apply their values with `SetFilter` or `SetRange` before `Open()`. When both filter types intentionally target the same field, ensure their intersection represents the required dataset. + +See sample: [`dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al`](dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al). + +## Anti Pattern + +Define a static filter in `DataItemTableFilter`, then apply a contradictory runtime filter to the same source field while expecting the runtime filter to replace the static one. Both filters remain effective and the query returns no rows. + +See sample: [`dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al`](dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al). + +## References + +Filtering in Query objects — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-query-filters \ No newline at end of file diff --git a/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.bad.al b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.bad.al new file mode 100644 index 0000000..f286334 --- /dev/null +++ b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.bad.al @@ -0,0 +1,37 @@ +query 50432 "Column Query Filter Bad" +{ + QueryType = Normal; + + elements + { + dataitem(SalesLine; "Sales Line") + { + column(DocumentNo; "Document No.") + { + } + column(LineQuantity; Quantity) + { + ColumnFilter = LineQuantity = filter(> 0); + } + } + } +} + +codeunit 50433 "Column Query Filter Bad" +{ + procedure ReadSmallPositiveLines() + var + SalesLineQuery: Query "Column Query Filter Bad"; + begin + // This replaces > 0, so negative quantities are also returned. + SalesLineQuery.SetFilter(LineQuantity, '<100'); + SalesLineQuery.Open(); + while SalesLineQuery.Read() do + ProcessLine(SalesLineQuery.DocumentNo, SalesLineQuery.LineQuantity); + SalesLineQuery.Close(); + end; + + local procedure ProcessLine(DocumentNo: Code[20]; Quantity: Decimal) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.good.al b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.good.al new file mode 100644 index 0000000..49353e6 --- /dev/null +++ b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.good.al @@ -0,0 +1,38 @@ +query 50434 "Column Query Filter Good" +{ + QueryType = Normal; + + elements + { + dataitem(SalesLine; "Sales Line") + { + DataItemTableFilter = Quantity = filter(> 0); + + column(DocumentNo; "Document No.") + { + } + column(LineQuantity; Quantity) + { + } + } + } +} + +codeunit 50435 "Column Query Filter Good" +{ + procedure ReadSmallPositiveLines() + var + SalesLineQuery: Query "Column Query Filter Good"; + begin + // This combines with the invariant Quantity > 0 dataitem filter. + SalesLineQuery.SetFilter(LineQuantity, '<100'); + SalesLineQuery.Open(); + while SalesLineQuery.Read() do + ProcessLine(SalesLineQuery.DocumentNo, SalesLineQuery.LineQuantity); + SalesLineQuery.Close(); + end; + + local procedure ProcessLine(DocumentNo: Code[20]; Quantity: Decimal) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.md b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.md new file mode 100644 index 0000000..9f9dd7a --- /dev/null +++ b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: query +keywords: [query, columnfilter, setfilter, setrange, filter-precedence, runtime-filter] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# SetFilter and SetRange overwrite Query ColumnFilter + +## Description + +`ColumnFilter` on a Query column or filter row defines a dynamic filter. A runtime `SetFilter` or `SetRange` on that same column or filter row replaces the `ColumnFilter`; it does not combine the two conditions. Rows excluded by the declarative filter can therefore reappear when the runtime filter omits that restriction. + +## Best Practice + +Place invariant restrictions in `DataItemTableFilter`, which runtime filters cannot overwrite. When a `ColumnFilter` is intentionally replaceable, make each runtime `SetFilter` or `SetRange` express the complete required condition before `Open()`. + +See sample: [`setfilter-overwrites-query-columnfilter.good.al`](setfilter-overwrites-query-columnfilter.good.al). + +## Anti Pattern + +Apply `SetFilter` or `SetRange` to a column or filter row and rely on its existing `ColumnFilter` to remain effective. The runtime call replaces that filter and can admit rows that the query definition appeared to exclude. + +See sample: [`setfilter-overwrites-query-columnfilter.bad.al`](setfilter-overwrites-query-columnfilter.bad.al). + +## References + +Filtering in Query objects — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-query-filters \ No newline at end of file diff --git a/microsoft/skills/review/al-query-review.md b/microsoft/skills/review/al-query-review.md index c52ddd8..e97a20a 100644 --- a/microsoft/skills/review/al-query-review.md +++ b/microsoft/skills/review/al-query-review.md @@ -32,22 +32,24 @@ Match relevant entries against changed `query` objects, variables typed as `Quer The following targeted checks cover every current `query` article: +- A `DataItemTableFilter` and a runtime `SetFilter` or `SetRange` constrain the same source field incompatibly, while the runtime call is intended to replace or broaden the static filter — `dataitemtablefilter-cannot-be-overwritten-at-runtime`. - `SetFilter` or `SetRange` occurs after `Open()` without a new `Open()` before the next `Read()` — `set-query-filters-before-open`. +- A runtime `SetFilter` or `SetRange` replaces a `ColumnFilter` on the same column or filter row, while later code relies on the declarative restriction remaining effective — `setfilter-overwrites-query-columnfilter`. - An already-open query is opened again as if that advanced the cursor, or a query variable is reused for an independent operation without `Clear` even though old filters must not carry over — `reopening-query-resets-cursor-but-keeps-filters`. Resolve layer conflicts per READ. When no query knowledge exists, emit `no-knowledge`; when knowledge exists but no article matches the changed Query usage, emit `completed` with no findings. ## Action -Evaluate every worklist article against the diff's Query call order and surrounding control flow. +Evaluate every worklist article against the Query definition, the diff's call order, and surrounding control flow. For filter-precedence findings, require both the declarative filter and the runtime call to be visible, and require local evidence that replacement, broadening, or retention of the original filter is intended. -- Emit `major` for an unambiguous Anti Pattern that can close the dataset, restart processing, or retain an unintended filter. +- Emit `major` for an unambiguous Anti Pattern that can close the dataset, restart processing, retain an unintended filter, produce an empty intersection, or admit rows excluded by an overwritten filter. - Emit `minor` when code contradicts a Best Practice but the resulting behavior depends on unseen control flow. - Do not emit applicability-only information. A Query article produces a finding only when the changed code violates its normative guidance. Set confidence to `high` for a locally visible call sequence and `medium` when aliases, helper calls, or missing context obscure the sequence. Domain-scoped agent findings follow DO's precision bar and remain capped at `minor`/`medium`. -Provide `suggested-code` only when moving a filter before `Open()` or adding `Clear` is a complete, local, unambiguous replacement. Otherwise set `suggested-code-omission-reason`. +Provide `suggested-code` only when moving a filter before `Open()`, adding `Clear`, moving an invariant restriction to `DataItemTableFilter`, or composing the complete runtime filter is a complete, local, unambiguous replacement. Otherwise set `suggested-code-omission-reason`. Outcome selection follows DO: `completed`, `no-knowledge`, `not-applicable`, `partial`, or `failed`. From 2c45021cb37d6b60f33ebb945fa88e4ac71462b2 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Tue, 15 Sep 2026 13:16:53 +0200 Subject: [PATCH 80/86] Add security knowledge: validate unauthenticated endpoint responses New remedial article for spotting when AL calls an endpoint that does not authenticate itself to the client (bare HttpClient.Get, blank SOAP SecretText, post-DisableHttpsCheck HTTP) and requires the response to be size-, schema-, and request/response-integrity-validated before it is trusted. Includes the BC-specific false-positive clarifications (platform buffers the full body, so an in-AL size check after buffering is correct; no DNS-rebinding/bounded-read demand; HTTPS not always enforceable) plus good/bad AL samples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...unauthenticated-response-before-use.bad.al | 23 ++++++++++ ...nauthenticated-response-before-use.good.al | 46 +++++++++++++++++++ ...ate-unauthenticated-response-before-use.md | 22 +++++++++ 3 files changed, 91 insertions(+) create mode 100644 microsoft/knowledge/security/validate-unauthenticated-response-before-use.bad.al create mode 100644 microsoft/knowledge/security/validate-unauthenticated-response-before-use.good.al create mode 100644 microsoft/knowledge/security/validate-unauthenticated-response-before-use.md diff --git a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.bad.al b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.bad.al new file mode 100644 index 0000000..ee0d25e --- /dev/null +++ b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.bad.al @@ -0,0 +1,23 @@ +codeunit 50541 "Sec Sample UnauthResp Bad" +{ + procedure IsVatNumberValid(RequestedCountryCode: Text; RequestedVatNumber: Text): Boolean + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + JsonResponse: JsonObject; + JsonToken: JsonToken; + Content: Text; + begin + // Anti-pattern: the endpoint is unauthenticated, yet the response is trusted with no + // size cap, no schema check, and no request-to-response integrity check. + HttpClient.Get('http://vat-service.example/check?cc=' + RequestedCountryCode + '&vat=' + RequestedVatNumber, Response); + Response.Content().ReadAs(Content); + JsonResponse.ReadFrom(Content); + + // Trusts valid=true for ANY input: a spoofed or MITM response that omits the echoed + // countryCode/vatNumber is accepted as valid for whatever number was requested. + if JsonResponse.Get('valid', JsonToken) then + exit(JsonToken.AsValue().AsBoolean()); + exit(false); + end; +} diff --git a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.good.al b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.good.al new file mode 100644 index 0000000..2c3e437 --- /dev/null +++ b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.good.al @@ -0,0 +1,46 @@ +codeunit 50540 "Sec Sample UnauthResp Good" +{ + // The public VAT validation service does not authenticate itself to us (no OAuth, no + // certificate, plain HTTP), so its response must be validated before it is trusted. + procedure IsVatNumberValid(RequestedCountryCode: Text; RequestedVatNumber: Text): Boolean + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + JsonResponse: JsonObject; + JsonToken: JsonToken; + Content: Text; + ResponseCountryCode: Text; + ResponseVatNumber: Text; + begin + HttpClient.Get('http://vat-service.example/check?cc=' + RequestedCountryCode + '&vat=' + RequestedVatNumber, Response); + if not Response.IsSuccessStatusCode() then + exit(false); + + Response.Content().ReadAs(Content); + + // 1) Size cap - the platform already buffered the whole body; reject abnormally large payloads. + if StrLen(Content) > 4096 then + Error('The VAT validation response exceeded the maximum allowed size and was rejected.'); + + // 2) Schema - require the expected scalar fields, not just a truthy flag. + if not JsonResponse.ReadFrom(Content) then + Error('The VAT validation response was not in the expected format and was rejected.'); + if not JsonResponse.Get('countryCode', JsonToken) then + Error('The VAT validation response did not include the requested identifiers and was rejected.'); + ResponseCountryCode := JsonToken.AsValue().AsText(); + if not JsonResponse.Get('vatNumber', JsonToken) then + Error('The VAT validation response did not include the requested identifiers and was rejected.'); + ResponseVatNumber := JsonToken.AsValue().AsText(); + + // 3) Integrity - the echoed identifiers must match the request, so a valid=true payload + // with the identifiers stripped cannot be accepted for an arbitrary VAT number. + if (UpperCase(ResponseCountryCode) <> UpperCase(RequestedCountryCode)) or + (UpperCase(ResponseVatNumber) <> UpperCase(RequestedVatNumber)) + then + Error('The VAT validation response did not match the requested identifiers and was rejected.'); + + if not JsonResponse.Get('valid', JsonToken) then + exit(false); + exit(JsonToken.AsValue().AsBoolean()); + end; +} diff --git a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md new file mode 100644 index 0000000..2e99e2c --- /dev/null +++ b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [unauthenticated, ssrf, httpclient, soap, response-validation, integrity, size-limit, disablehttpscheck, temp-blob, vies] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Validate responses from unauthenticated endpoints before trusting them + +## Description + +When AL calls an external endpoint that does not authenticate *itself* to the client, the response is fully attacker-influenceable — cleartext MITM, a spoofed or compromised host, DNS/redirect games, or simply a misbehaving public service. Recognizing that a call is unauthenticated is the first review step, and the signals are BC-specific: a bare `HttpClient.Get`/`Post` with no `Authorization` header, no acquired OAuth token, and no client certificate; a SOAP request whose credentials are blank, such as `SOAP Web Service Request Mgt.SetGlobals(..., '', BlankSecretText)`; or any request issued after `DisableHttpsCheck()` over plain HTTP (for example the EU VIES VAT service, whose default endpoint is `http://`). Because the BC platform HTTP stack buffers the entire response body before AL is handed the stream or `Temp Blob`, the whole payload is already in memory by the time AL parses it — so the response must be range- and shape-checked in AL *before* any of it is written to tax, VAT, customer, or vendor tables. + +## Best Practice + +Before parsing or trusting a response from an unauthenticated endpoint: (1) enforce a maximum size — reject when the buffered `Temp Blob` length or `Content-Length` exceeds a small cap sized to the expected payload; (2) validate the schema/shape — require the specific scalar nodes you expect, not merely "the body contains a truthy flag"; (3) enforce request-to-response integrity — when the protocol echoes the identifiers you queried (VIES echoes `countryCode`/`vatNumber`; a public-IP service echoes an IP string), require them to be present and to match the request, so a response carrying only `valid=true` cannot be accepted for an arbitrary input; (4) on rejection raise an `Error` and record a security audit via `Audit Log.LogAuditMessage(...)` plus telemetry. See sample: `validate-unauthenticated-response-before-use.good.al`. For validating the outbound target/host, see `validate-user-configurable-urls.md`; for authenticating outbound calls, see `prefer-oauth2-over-api-keys-for-external-http-calls.md`. + +## Anti Pattern + +Feeding the parsed response straight into business logic — load the XML/JSON, read a `valid` flag or an IP-shaped substring, then `Customer.Modify()` — trusting it purely because the HTTP call returned 2xx, with no size, shape, or echoed-identifier check. Reviewers should flag an unauthenticated outbound call (no `Authorization`/OAuth/cert, blank SOAP `SecretText`, or a request after `DisableHttpsCheck`) whose response is parsed and persisted without a preceding size cap, schema check, and request-to-response integrity check. Do NOT, however, demand a streaming or bounded read that aborts the transfer mid-download, nor a resolved-IP/DNS-rebinding check: the platform buffers the full body before AL sees it and AL has no connection-time or DNS hook, so an in-AL size check necessarily runs after buffering and host-rebinding defense belongs to the platform egress layer — raising those is a false positive. HTTPS is likewise not always enforceable (VIES is HTTP by design); the mitigation there is response validation, not scheme enforcement. See sample: `validate-unauthenticated-response-before-use.bad.al`. From 58b3be23abee90269adbe434d8ae395ca8e78a30 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Tue, 15 Sep 2026 13:19:37 +0200 Subject: [PATCH 81/86] Name the three required checks explicitly: response size, schema compliance, content integrity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../security/validate-unauthenticated-response-before-use.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md index 2e99e2c..27db51f 100644 --- a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md +++ b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md @@ -11,11 +11,11 @@ application-area: [all] ## Description -When AL calls an external endpoint that does not authenticate *itself* to the client, the response is fully attacker-influenceable — cleartext MITM, a spoofed or compromised host, DNS/redirect games, or simply a misbehaving public service. Recognizing that a call is unauthenticated is the first review step, and the signals are BC-specific: a bare `HttpClient.Get`/`Post` with no `Authorization` header, no acquired OAuth token, and no client certificate; a SOAP request whose credentials are blank, such as `SOAP Web Service Request Mgt.SetGlobals(..., '', BlankSecretText)`; or any request issued after `DisableHttpsCheck()` over plain HTTP (for example the EU VIES VAT service, whose default endpoint is `http://`). Because the BC platform HTTP stack buffers the entire response body before AL is handed the stream or `Temp Blob`, the whole payload is already in memory by the time AL parses it — so the response must be range- and shape-checked in AL *before* any of it is written to tax, VAT, customer, or vendor tables. +When AL calls an external endpoint that does not authenticate *itself* to the client, the response is fully attacker-influenceable — cleartext MITM, a spoofed or compromised host, DNS/redirect games, or simply a misbehaving public service. Recognizing that a call is unauthenticated is the first review step, and the signals are BC-specific: a bare `HttpClient.Get`/`Post` with no `Authorization` header, no acquired OAuth token, and no client certificate; a SOAP request whose credentials are blank, such as `SOAP Web Service Request Mgt.SetGlobals(..., '', BlankSecretText)`; or any request issued after `DisableHttpsCheck()` over plain HTTP (for example the EU VIES VAT service, whose default endpoint is `http://`). Because the BC platform HTTP stack buffers the entire response body before AL is handed the stream or `Temp Blob`, the whole payload is already in memory by the time AL parses it — so the response must pass three checks in AL — **response size**, **schema compliance**, and **content integrity** — *before* any of it is written to tax, VAT, customer, or vendor tables. ## Best Practice -Before parsing or trusting a response from an unauthenticated endpoint: (1) enforce a maximum size — reject when the buffered `Temp Blob` length or `Content-Length` exceeds a small cap sized to the expected payload; (2) validate the schema/shape — require the specific scalar nodes you expect, not merely "the body contains a truthy flag"; (3) enforce request-to-response integrity — when the protocol echoes the identifiers you queried (VIES echoes `countryCode`/`vatNumber`; a public-IP service echoes an IP string), require them to be present and to match the request, so a response carrying only `valid=true` cannot be accepted for an arbitrary input; (4) on rejection raise an `Error` and record a security audit via `Audit Log.LogAuditMessage(...)` plus telemetry. See sample: `validate-unauthenticated-response-before-use.good.al`. For validating the outbound target/host, see `validate-user-configurable-urls.md`; for authenticating outbound calls, see `prefer-oauth2-over-api-keys-for-external-http-calls.md`. +Before parsing or trusting a response from an unauthenticated endpoint, apply all three of these checks before the payload reaches business logic: (1) **Response size** — reject when the buffered `Temp Blob` length or `Content-Length` exceeds a small cap sized to the expected payload; (2) **Schema compliance** — require the specific scalar nodes/fields you expect in the expected shape, not merely "the body contains a truthy flag"; (3) **Content integrity** — when the protocol echoes the identifiers you queried (VIES echoes `countryCode`/`vatNumber`; a public-IP service echoes an IP string), require them to be present and to match the request, so a response carrying only `valid=true` cannot be accepted for an arbitrary input. On any failing check, raise an `Error` and record a security audit via `Audit Log.LogAuditMessage(...)` plus telemetry. See sample: `validate-unauthenticated-response-before-use.good.al`. For validating the outbound target/host, see `validate-user-configurable-urls.md`; for authenticating outbound calls, see `prefer-oauth2-over-api-keys-for-external-http-calls.md`. ## Anti Pattern From 5bda05492752a5954b282dbce5fef8782b8ab474 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Tue, 15 Sep 2026 13:48:59 +0200 Subject: [PATCH 82/86] Link samples using the READ markdown-link convention Use [\slug.good.al\](slug.good.al) form so tools/Knowledge-Retrieval.ps1 Assert-SampleLink validation passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../security/validate-unauthenticated-response-before-use.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md index 27db51f..ca09d56 100644 --- a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md +++ b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md @@ -15,8 +15,8 @@ When AL calls an external endpoint that does not authenticate *itself* to the cl ## Best Practice -Before parsing or trusting a response from an unauthenticated endpoint, apply all three of these checks before the payload reaches business logic: (1) **Response size** — reject when the buffered `Temp Blob` length or `Content-Length` exceeds a small cap sized to the expected payload; (2) **Schema compliance** — require the specific scalar nodes/fields you expect in the expected shape, not merely "the body contains a truthy flag"; (3) **Content integrity** — when the protocol echoes the identifiers you queried (VIES echoes `countryCode`/`vatNumber`; a public-IP service echoes an IP string), require them to be present and to match the request, so a response carrying only `valid=true` cannot be accepted for an arbitrary input. On any failing check, raise an `Error` and record a security audit via `Audit Log.LogAuditMessage(...)` plus telemetry. See sample: `validate-unauthenticated-response-before-use.good.al`. For validating the outbound target/host, see `validate-user-configurable-urls.md`; for authenticating outbound calls, see `prefer-oauth2-over-api-keys-for-external-http-calls.md`. +Before parsing or trusting a response from an unauthenticated endpoint, apply all three of these checks before the payload reaches business logic: (1) **Response size** — reject when the buffered `Temp Blob` length or `Content-Length` exceeds a small cap sized to the expected payload; (2) **Schema compliance** — require the specific scalar nodes/fields you expect in the expected shape, not merely "the body contains a truthy flag"; (3) **Content integrity** — when the protocol echoes the identifiers you queried (VIES echoes `countryCode`/`vatNumber`; a public-IP service echoes an IP string), require them to be present and to match the request, so a response carrying only `valid=true` cannot be accepted for an arbitrary input. On any failing check, raise an `Error` and record a security audit via `Audit Log.LogAuditMessage(...)` plus telemetry. See sample: [`validate-unauthenticated-response-before-use.good.al`](validate-unauthenticated-response-before-use.good.al). For validating the outbound target/host, see `validate-user-configurable-urls.md`; for authenticating outbound calls, see `prefer-oauth2-over-api-keys-for-external-http-calls.md`. ## Anti Pattern -Feeding the parsed response straight into business logic — load the XML/JSON, read a `valid` flag or an IP-shaped substring, then `Customer.Modify()` — trusting it purely because the HTTP call returned 2xx, with no size, shape, or echoed-identifier check. Reviewers should flag an unauthenticated outbound call (no `Authorization`/OAuth/cert, blank SOAP `SecretText`, or a request after `DisableHttpsCheck`) whose response is parsed and persisted without a preceding size cap, schema check, and request-to-response integrity check. Do NOT, however, demand a streaming or bounded read that aborts the transfer mid-download, nor a resolved-IP/DNS-rebinding check: the platform buffers the full body before AL sees it and AL has no connection-time or DNS hook, so an in-AL size check necessarily runs after buffering and host-rebinding defense belongs to the platform egress layer — raising those is a false positive. HTTPS is likewise not always enforceable (VIES is HTTP by design); the mitigation there is response validation, not scheme enforcement. See sample: `validate-unauthenticated-response-before-use.bad.al`. +Feeding the parsed response straight into business logic — load the XML/JSON, read a `valid` flag or an IP-shaped substring, then `Customer.Modify()` — trusting it purely because the HTTP call returned 2xx, with no size, shape, or echoed-identifier check. Reviewers should flag an unauthenticated outbound call (no `Authorization`/OAuth/cert, blank SOAP `SecretText`, or a request after `DisableHttpsCheck`) whose response is parsed and persisted without a preceding size cap, schema check, and request-to-response integrity check. Do NOT, however, demand a streaming or bounded read that aborts the transfer mid-download, nor a resolved-IP/DNS-rebinding check: the platform buffers the full body before AL sees it and AL has no connection-time or DNS hook, so an in-AL size check necessarily runs after buffering and host-rebinding defense belongs to the platform egress layer — raising those is a false positive. HTTPS is likewise not always enforceable (VIES is HTTP by design); the mitigation there is response validation, not scheme enforcement. See sample: [`validate-unauthenticated-response-before-use.bad.al`](validate-unauthenticated-response-before-use.bad.al). From 450e5965e180cb5c0fa9c69e4375b081ff43de01 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 17 Sep 2026 13:34:22 +0200 Subject: [PATCH 83/86] Add BCQuality logo and brand assets (#190) * Add BCQuality logo and brand assets Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Remove brand assets link from README Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Use horizontal logo banner in README Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Enlarge banner wordmark Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 4 ++++ docs/README.md | 5 +++++ docs/assets/bcq-logo.svg | 26 ++++++++++++++++++++++++ docs/assets/bcq-mark.svg | 22 ++++++++++++++++++++ docs/brand-assets.md | 43 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+) create mode 100644 docs/assets/bcq-logo.svg create mode 100644 docs/assets/bcq-mark.svg create mode 100644 docs/brand-assets.md diff --git a/README.md b/README.md index e9e18d5..147a84c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ BCQuality logo +

+ # BCQuality Quality skills and knowledge that help AI tools make better Business Central diff --git a/docs/README.md b/docs/README.md index d9ca27b..7210cb7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,7 @@ +

+ BCQuality mark +

+ # BCQuality documentation **New to BCQuality? Start with the [quick start](../README.md#quick-start).** @@ -32,3 +36,4 @@ prerequisites for using the plugin. | [DO](../skills/do.md) | Action-skill format and structured output contract. | | [WRITE](../skills/write.md) | Knowledge-authoring rules. | | [Review evaluation](../evaluation/README.md) | Sample conventions, fixture preparation, and scoring. | +| [Brand assets](brand-assets.md) | BCQ logo files and usage guidance. | diff --git a/docs/assets/bcq-logo.svg b/docs/assets/bcq-logo.svg new file mode 100644 index 0000000..0c7aae5 --- /dev/null +++ b/docs/assets/bcq-logo.svg @@ -0,0 +1,26 @@ + + BCQuality banner logo + A blue letter Q crossed by a teal quality check beside the BCQuality wordmark. + + + + + + + + + + + + + + + + + + + + + + BCQuality + diff --git a/docs/assets/bcq-mark.svg b/docs/assets/bcq-mark.svg new file mode 100644 index 0000000..0b6c00e --- /dev/null +++ b/docs/assets/bcq-mark.svg @@ -0,0 +1,22 @@ + + BCQuality mark + A blue letter Q crossed by a teal quality check. + + + + + + + + + + + + + + + + + + + diff --git a/docs/brand-assets.md b/docs/brand-assets.md new file mode 100644 index 0000000..e578fcd --- /dev/null +++ b/docs/brand-assets.md @@ -0,0 +1,43 @@ +# BCQ brand assets + +

+ BCQuality logo +

+ +The BCQuality mark combines a **Q** with a check to represent review, +confidence, and quality. The vector artwork follows the supplied blue-to-teal +concept and connects the mark to the split-color BCQuality wordmark. A true +circular ring and square-ended 45-degree bars keep the check and Q tail aligned +at exact right angles; the check's vertical cut and the tail's horizontal cut +match the source concept. + +## Available artwork + +| Asset | Best use | +| --- | --- | +| [`bcq-logo.svg`](assets/bcq-logo.svg) | Horizontal banner with the mark and wordmark. | +| [`bcq-mark.svg`](assets/bcq-mark.svg) | Symbol without the wordmark. | + +The SVG files can be scaled without losing quality. + +## Color palette + +The artwork uses the dominant colors sampled from the supplied Business Central +logo reference. + +| Color | Hex | +| --- | --- | +| Deep blue | `#086194` | +| Blue | `#138EB7` | +| Cyan | `#17ABCB` | +| Aqua | `#2CD2CD` | +| Mint | `#57E6CB` | +| Light mint | `#9BF2C8` | + +## Usage + +- Prefer the full logo when at least 320 pixels of horizontal space is + available; use the mark at smaller sizes. +- Preserve the artwork's proportions, colors, and orientation. +- Use `BCQuality logo` as alternative text unless nearby text already names the + project, in which case the image can be decorative. From 2b96f5226d469542646d73e010c4099da46995a5 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 17 Sep 2026 13:35:46 +0200 Subject: [PATCH 84/86] Update logo width and remove project title Reduced logo width in README and removed title. --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 147a84c..bdf10f3 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,7 @@

- BCQuality logo + BCQuality logo

-# BCQuality - Quality skills and knowledge that help AI tools make better Business Central development decisions: catch BC-specific defects, avoid misleading advice, and explain findings with references you can read. From d209cd0f73daadb1ca3b7fc9e476e6b1453c29a9 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 17 Sep 2026 13:38:04 +0200 Subject: [PATCH 85/86] Left-align README logo (#191) * Add BCQuality logo and brand assets Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Remove brand assets link from README Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Use horizontal logo banner in README Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Enlarge banner wordmark Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bdf10f3..3f66a4e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

+

BCQuality logo

From b91443beec785606e08274dea3f402a99aaec7bd Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 17 Sep 2026 13:38:34 +0200 Subject: [PATCH 86/86] Update logo width in README Reduced logo width in README from 500 to 300 pixels. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3f66a4e..daea405 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- BCQuality logo + BCQuality logo

Quality skills and knowledge that help AI tools make better Business Central