Add error-handling knowledge domain and review leaf skill

Seed the first new AL knowledge domain toward issue #34: a fully wired
error-handling domain that the review pipeline actually consumes.

- 3 knowledge articles (+ good/bad AL samples):
  - prefer-errorinfo-for-actionable-errors (ErrorInfo Fix-it/Show-it actions)
  - collect-validation-errors-with-errorbehavior (ErrorBehavior::Collect)
  - errortype-internal-vs-client-for-diagnostics (ErrorType Internal vs Client)
- New leaf skill al-error-handling-review.md, modeled on al-performance-review
- Wire the leaf into al-code-review (sub-skills, Source, description)
- README: six -> seven leaf skills

Validated: frontmatter validator clean; knowledge-index check passes
(deterministic, full coverage, selection inputs intact).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot 2026-06-23 10:35:24 +02:00
parent 822cae1b27
commit 8901b83e07
12 changed files with 374 additions and 2 deletions

View file

@ -0,0 +1,21 @@
codeunit 50187 "Collect Errors Bad Sample"
{
procedure ValidateAllItems()
var
Item: Record Item;
ErrorText: Text;
begin
// Hand-rolled accumulation: reimplements the platform feature, loses each
// error's ErrorInfo structure, and skips telemetry classification.
if Item.FindSet() then
repeat
if Item.Description = '' then
ErrorText += StrSubstNo('Item %1 has no description.\', Item."No.");
if Item."Unit Cost" <= 0 then
ErrorText += StrSubstNo('Item %1 must have a positive unit cost.\', Item."No.");
until Item.Next() = 0;
if ErrorText <> '' then
Error(ErrorText);
end;
}

View file

@ -0,0 +1,37 @@
codeunit 50185 "Collect Errors Good Sample"
{
[ErrorBehavior(ErrorBehavior::Collect)]
procedure ValidateAllItems()
var
Item: Record Item;
CollectedErrors: List of [ErrorInfo];
CollectedError: ErrorInfo;
ErrorText: Text;
begin
if Item.FindSet() then
repeat
// Run each item in its own context so one failure does not abandon the rest.
Codeunit.Run(Codeunit::"Collect Errors Item Check", Item);
until Item.Next() = 0;
if HasCollectedErrors() then begin
CollectedErrors := GetCollectedErrors();
foreach CollectedError in CollectedErrors do
ErrorText += CollectedError.Message() + '\';
Message('The following must be fixed before posting:\%1', ErrorText);
end;
end;
}
codeunit 50186 "Collect Errors Item Check"
{
TableNo = Item;
trigger OnRun()
begin
if Rec.Description = '' then
Error('Item %1 has no description.', Rec."No.");
if Rec."Unit Cost" <= 0 then
Error('Item %1 must have a positive unit cost.', Rec."No.");
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: error-handling
keywords: [collectible-errors, errorbehavior, collect, getcollectederrors, hascollectederrors, validation, batch]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Collect validation errors with ErrorBehavior::Collect and handle the collected list
## 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.
## 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.
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.
See sample: `collect-validation-errors-with-errorbehavior.bad.al`.

View file

@ -0,0 +1,14 @@
codeunit 50191 "Error Type Bad Sample"
{
procedure ApplyLedgerBucket(BucketId: Integer)
begin
// Developer-facing detail shown straight to the user, and no structured telemetry signal.
if not BucketInitialized(BucketId) then
Error('Unexpected state: ledger bucket %1 not initialized', BucketId);
end;
local procedure BucketInitialized(BucketId: Integer): Boolean
begin
exit(false);
end;
}

View file

@ -0,0 +1,19 @@
codeunit 50190 "Error Type Good Sample"
{
procedure ApplyLedgerBucket(BucketId: Integer)
var
InternalErr: ErrorInfo;
begin
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;
local procedure BucketInitialized(BucketId: Integer): Boolean
begin
exit(false);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: error-handling
keywords: [errorinfo, errortype, internal, client, telemetry, diagnostics, generic-message]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Set ErrorInfo.ErrorType to Internal for defects you want in telemetry but not in the user's face
## Description
`ErrorInfo.ErrorType` controls where an error's message is shown. With `ErrorType::Client` — the behaviour of a normal `Error` — the message is both shown to the user and sent to telemetry. With `ErrorType::Internal` the user sees a generic message while the specific message you set is sent to telemetry only. The distinction matters for *unexpected* failures — a broken invariant, a failed internal assertion, a "this should never happen" branch — where the technical detail helps the partner diagnose the defect but would only confuse the end user. LLMs are unaware `ErrorType` exists, so they expose raw internal-failure text directly to users.
## 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`.
See sample: `errortype-internal-vs-client-for-diagnostics.good.al`.
## Anti Pattern
Raising an internal failure with a plain `Error('Unexpected state: ledger bucket %1 not initialized', BucketId)`. The user is shown a technical message they can do nothing about, and the signal is buried in a generic error rather than carried as structured telemetry detail. Detection: an `Error` whose wording targets a developer ("unexpected", "should not happen", raw internal identifiers) raised with default `Client` visibility instead of an `ErrorInfo` marked `ErrorType::Internal`.
See sample: `errortype-internal-vs-client-for-diagnostics.bad.al`.

View file

@ -0,0 +1,21 @@
table 50182 "Actionable Error Bad Sample"
{
fields
{
field(1; "Entry No."; Integer) { }
field(2; "Qty. to Invoice"; Decimal)
{
trigger OnValidate()
begin
// Dead-end error: the code knows the maximum but offers the user no way to apply it.
if "Qty. to Invoice" > MaxQtyToInvoice() then
Error('You cannot invoice more than %1 units.', MaxQtyToInvoice());
end;
}
}
local procedure MaxQtyToInvoice(): Decimal
begin
exit(10);
end;
}

View file

@ -0,0 +1,44 @@
table 50180 "Actionable Error Good Sample"
{
fields
{
field(1; "Entry No."; Integer) { }
field(2; "Qty. to Invoice"; Decimal)
{
trigger OnValidate()
var
CannotInvoiceErr: ErrorInfo;
begin
if "Qty. to Invoice" > MaxQtyToInvoice() then begin
CannotInvoiceErr.Title := 'Qty. to Invoice isn''t valid';
CannotInvoiceErr.Message := StrSubstNo('You cannot invoice more than %1 units.', MaxQtyToInvoice());
CannotInvoiceErr.DetailedMessage := 'Reduce the quantity to invoice, or apply the maximum allowed.';
CannotInvoiceErr.RecordId := Rec.RecordId();
CannotInvoiceErr.AddAction(
StrSubstNo('Set value to %1', MaxQtyToInvoice()),
Codeunit::"Actionable Error Fixit Sample",
'SetQtyToMax');
Error(CannotInvoiceErr);
end;
end;
}
}
local procedure MaxQtyToInvoice(): Decimal
begin
exit(10);
end;
}
codeunit 50181 "Actionable Error Fixit Sample"
{
procedure SetQtyToMax(SourceError: ErrorInfo)
var
Line: Record "Actionable Error Good Sample";
begin
if Line.Get(SourceError.RecordId) then begin
Line.Validate("Qty. to Invoice", 10);
Line.Modify(true);
end;
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [23..28]
domain: error-handling
keywords: [errorinfo, actionable-errors, fix-it, show-it, addaction, addnavigationaction, error-dialog]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Prefer ErrorInfo with recommended actions over a plain Error for recoverable failures
## Description
A plain `Error('text')` ends the operation with a dead-end dialog: the user reads the message but the system offers no way forward. The `ErrorInfo` data type, combined with the actionable-errors framework added in 2023 release wave 2, lets an error carry a recommended action the user can take to unblock themselves without leaving their task. Two kinds exist: a **Fix-it** action (`AddAction`), used when the code already knows the correct value and can apply it in one step, and a **Show-it** action (`AddNavigationAction` together with `PageNo`), used when the correction lives on a related record the user should be taken to. An error dialog renders at most two recommended actions. LLMs trained on older AL almost always emit a bare `Error(...)` and rarely reach for `ErrorInfo`, so this guidance is remedial.
## Best Practice
Build an `ErrorInfo`, set `Title`, `Message`, and `DetailedMessage`, then attach the action that matches the situation. For a Fix-it, call `AddAction(Caption, Codeunit::Handler, 'MethodName')` where the handler method (which receives the `ErrorInfo`) applies the known-good value; phrase the caption as "Set value to …". For a Show-it, set `PageNo := Page::"…"`, set `RecordId` so navigation opens the right record, and call `AddNavigationAction('Show …')`. Raise it with `Error(ErrorInfo)`. Reserve recommended actions for cases where the solution is genuinely known and the user has permission to apply it.
See sample: `prefer-errorinfo-for-actionable-errors.good.al`.
## Anti Pattern
Surfacing a recoverable validation failure with `Error('You cannot invoice more than %1 units.', MaxQty)` and nothing else. The user is blocked with no offered remedy even though the code knows the maximum and could set it. The detection signal: an `Error` call in a validation or posting path whose message names a specific correct value or a specific related page, with no surrounding `ErrorInfo`, `AddAction`, or `AddNavigationAction`. Replace it with an `ErrorInfo` that carries the corresponding Fix-it or Show-it action.
See sample: `prefer-errorinfo-for-actionable-errors.bad.al`.