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/.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..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. @@ -88,18 +90,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 +115,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 @@ -155,9 +149,12 @@ 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. +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/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/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/security/classify-every-field-with-dataclassification.bad.al b/community/knowledge/security/classify-every-field-with-dataclassification.bad.al deleted file mode 100644 index a9ae935..0000000 --- a/community/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/community/knowledge/security/classify-every-field-with-dataclassification.good.al b/community/knowledge/security/classify-every-field-with-dataclassification.good.al deleted file mode 100644 index baa3079..0000000 --- a/community/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/community/knowledge/security/classify-every-field-with-dataclassification.md b/community/knowledge/security/classify-every-field-with-dataclassification.md deleted file mode 100644 index 1b854aa..0000000 --- a/community/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/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/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/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/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/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/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 0e2f000..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 "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. - field(2; "Contact Email"; Text[80]) { } + // 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(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 e1e7116..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,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, renaming, 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. +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 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 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/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/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/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/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/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/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 94% rename from community/knowledge/error-handling/fielderror-default-message-logic.md rename to microsoft/knowledge/error-handling/fielderror-default-message-logic.md index b0641dc..c02bb4f 100644 --- a/community/knowledge/error-handling/fielderror-default-message-logic.md +++ b/microsoft/knowledge/error-handling/fielderror-default-message-logic.md @@ -1,20 +1,22 @@ ---- -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 +--- +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. + +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. + +See sample: `fielderror-default-message-logic.bad.al`. \ 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..1353c03 100644 --- a/community/knowledge/error-handling/fielderror-vs-testfield.md +++ b/microsoft/knowledge/error-handling/fielderror-vs-testfield.md @@ -1,20 +1,22 @@ ---- -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. + +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.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..52e3e40 --- /dev/null +++ b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md @@ -0,0 +1,30 @@ +--- +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`. + +## 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/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/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/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/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. 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/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-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/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/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/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/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/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 92ef3cf..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; 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. 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/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-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 afd57a2..2ffa386 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al @@ -1,21 +1,60 @@ +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() var - Customer: Record Customer; - RowsInChunk: Integer; - ChunkSize: Integer; + LastCustomerNo: Code[20]; begin - ChunkSize := 500; - if Customer.FindSet(true) then + // The outer loop owns checkpoints; the per-row loop contains no Commit. + while NormalizeNextChunk(LastCustomerNo) do + Commit(); + end; + + local procedure NormalizeNextChunk(var LastCustomerNo: Code[20]): Boolean + var + Customer: Record Customer; + TempCustomer: Record Customer temporary; + CustomerChunk: Query "Perf Customer Chunk"; + LastChunkCustomerNo: Code[20]; + begin + CustomerChunk.TopNumberOfRows(500); + if LastCustomerNo <> '' 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); + + Customer.LockTable(); + if TempCustomer.FindSet() then repeat - Customer.Name := UpperCase(Customer.Name); - Customer.Modify(); - RowsInChunk += 1; - if RowsInChunk >= ChunkSize then begin - Commit(); - RowsInChunk := 0; + if Customer.Get(TempCustomer."No.") then begin + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); end; - until Customer.Next() = 0; + 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 98e0c38..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,17 +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, 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 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/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/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/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/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/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/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/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/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/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/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/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-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/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. 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.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..f9245b1 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 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`. ## 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/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/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 8653ecf..0000000 --- a/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [error, strsubstno, direct-substitution, telemetry, classification, label] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# `Error()` with direct substitution parameters is always safe for telemetry - -## Description - -When `Error()` is called with a format string and direct substitution parameters (`%1`, `%2`, …), the BC platform intercepts the call, inspects each parameter individually, and applies the `DataClassification` of the source field — stripping or masking sensitive data before writing the message to telemetry. This is true regardless of whether a parameter is a record field reference, a local variable, a function return value, or any other expression. Patterns such as `Error('Invalid email: %1', Customer."E-Mail")` are therefore safe even when the parameter is PII: the platform sees `Customer."E-Mail"` as a `CustomerContent` field reference and handles it correctly. - -## Best Practice - -Pass values to `Error()` as direct substitution parameters — either inline or via a `Label` with `Comment = '%1 = …'` placeholders. Let the platform do the per-parameter classification. This works equally well for record fields, local text variables, and document IDs. - -See sample: `error-direct-substitution-safe-for-telemetry.good.al`. - -## Anti Pattern - -Treating any `Error()` call that mentions PII as a leak. A review skill that flags `Error('Invalid email: %1', EmailAddress)` is wrong; the platform handles that pattern correctly. The only `Error()` shape that genuinely leaks PII to telemetry is the pre-built `StrSubstNo` form covered in `avoid-strsubstno-prebuild-before-error.md`. diff --git a/microsoft/knowledge/privacy/error-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/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/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/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/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/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/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/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/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/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/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/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`. 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 67% rename from community/knowledge/security/secrets-isolated-storage.md rename to microsoft/knowledge/security/secrets-isolated-storage.md index c87753d..1434c1a 100644 --- a/community/knowledge/security/secrets-isolated-storage.md +++ b/microsoft/knowledge/security/secrets-isolated-storage.md @@ -9,16 +9,18 @@ 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. + +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/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..0eb6cb8 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,8 +15,8 @@ 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 -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.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..689a7b6 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`. 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/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`. 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/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/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/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/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..87b539b --- /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 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 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 + +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. 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. 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/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 95% rename from community/knowledge/ui/page-background-tasks.md rename to microsoft/knowledge/ui/page-background-tasks.md index 83fd87c..c4cccb8 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: [15..] +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/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/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/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/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/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. 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/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. 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/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/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/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-appsource-review.md b/microsoft/skills/review/al-appsource-review.md new file mode 100644 index 0000000..43a2e20 --- /dev/null +++ b/microsoft/skills/review/al-appsource-review.md @@ -0,0 +1,133 @@ +--- +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`, 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`). + +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`. + +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. +- 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: + +- `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. Every finding this skill emits MUST set `findings[].domain` to `"AppSource"`. 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", + "domain": "AppSource", + "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-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md index 4238bca..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: @@ -77,7 +89,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 +112,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 +126,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 3ea0356..9b3f934 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -22,6 +22,11 @@ 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-query-review.md + - microsoft/skills/review/al-appsource-review.md + - microsoft/skills/review/al-telemetry-review.md --- # AL code review @@ -34,7 +39,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 @@ -60,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 @@ -72,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`. 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 @@ -85,11 +93,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`. @@ -113,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: @@ -139,21 +150,23 @@ 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": "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" + "from-sub-skill": "al-performance-review", + "domain": "Performance" }, { "id": "microsoft/knowledge/security/secrettext-for-credentials.md", @@ -168,10 +181,11 @@ 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": "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": { @@ -179,10 +193,11 @@ 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" + "from-sub-skill": "al-security-review", + "domain": "Security" }, { "id": "agent:missing-error-handling-on-http-client", @@ -195,7 +210,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": [], @@ -220,20 +236,22 @@ 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": "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" + "confidence": "high", + "domain": "Performance" } ], "suppressed": [] @@ -258,10 +276,11 @@ 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": "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": { @@ -269,9 +288,10 @@ 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" + "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 new file mode 100644 index 0000000..2386613 --- /dev/null +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -0,0 +1,134 @@ +--- +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`, `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. + +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`. +- 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`. + +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. +- 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: + +- `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. Every finding this skill emits MUST set `findings[].domain` to `"Data Modeling"`. 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", + "domain": "Data Modeling", + "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-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index 91228aa..39851ac 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-review.md @@ -38,11 +38,21 @@ 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`, `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. +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. @@ -53,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: @@ -77,7 +87,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 { @@ -100,7 +110,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", @@ -113,7 +124,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 0fe9479..de2700c 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`. @@ -70,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: @@ -94,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 { @@ -117,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", @@ -130,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 c859b75..0031e8f 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,13 +47,23 @@ 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`. + +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: @@ -77,7 +87,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 { @@ -100,7 +110,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", @@ -113,7 +124,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 5b92d2b..3262179 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -38,11 +38,22 @@ 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`, `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. + 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. @@ -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 (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: @@ -77,7 +88,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 { @@ -100,20 +111,22 @@ 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": "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" + "confidence": "high", + "domain": "Performance" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index bf60f5d..ea95269 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -38,10 +38,17 @@ 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. + +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`. @@ -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 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: @@ -61,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`. @@ -77,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 `"Privacy"`. A populated example: ```json { @@ -100,7 +107,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-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 1986934..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: @@ -77,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 `"Security"`. A populated example: ```json { @@ -100,10 +107,11 @@ 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": "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,9 +119,10 @@ 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" + "confidence": "medium", + "domain": "Security" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index fb15dba..ee4daf5 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. @@ -53,13 +60,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, 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: - `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`. @@ -75,20 +84,20 @@ 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 { "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", + "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", @@ -97,7 +106,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 new file mode 100644 index 0000000..4a758f0 --- /dev/null +++ b/microsoft/skills/review/al-telemetry-review.md @@ -0,0 +1,104 @@ +--- +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. +- 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. + +## 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. +- 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: + +- `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. Every finding this skill emits MUST set `findings[].domain` to `"Telemetry"`. 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..2483f12 --- /dev/null +++ b/microsoft/skills/review/al-testing-review.md @@ -0,0 +1,133 @@ +--- +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(...)]`, `[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` 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`. + +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. +- 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: + +- `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. Every finding this skill emits MUST set `findings[].domain` to `"Testing"`. 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", + "domain": "Testing", + "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": [] +} +``` diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index 2f99c12..81ab12e 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`, 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. @@ -53,13 +53,15 @@ 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). - `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`. @@ -69,13 +71,13 @@ 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. ## 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 { @@ -97,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 3b91d67..879e788 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. @@ -53,11 +56,11 @@ 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: -- `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. @@ -77,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 { @@ -99,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 4109722..cfa6fdd 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. @@ -53,7 +54,9 @@ 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. Set `confidence` to: @@ -71,27 +74,27 @@ 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. ## 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 { "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, @@ -100,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", @@ -113,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/.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/" + ] } diff --git a/skills/bcquality-al-review/SKILL.md b/skills/bcquality-al-review/SKILL.md index 937e06a..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. @@ -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 - (`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. + 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. @@ -85,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. diff --git a/skills/do.md b/skills/do.md index 777f89f..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 @@ -95,6 +108,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" } @@ -170,6 +184,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. @@ -185,10 +201,23 @@ 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. +**`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 +255,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 +317,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. - - diff --git a/skills/write.md b/skills/write.md index 754fe1e..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. @@ -85,7 +104,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