)` when the flow must not show UI. No path should issue the request without approval.
-See sample: `privacy-notice-consent-for-external-data-transfer.good.al`.
+See sample: [`privacy-notice-consent-for-external-data-transfer.good.al`](privacy-notice-consent-for-external-data-transfer.good.al).
## Anti Pattern
A custom integration that posts data without checking its own notice, or that gates the call with a built-in ID such as the Exchange privacy notice ID. Consent for one service does not authorize another.
-See sample: `privacy-notice-consent-for-external-data-transfer.bad.al`.
+See sample: [`privacy-notice-consent-for-external-data-transfer.bad.al`](privacy-notice-consent-for-external-data-transfer.bad.al).
diff --git a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md
index 4e76779..a9f12ff 100644
--- a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md
+++ b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md
@@ -17,7 +17,7 @@ The current extension point is `Codeunit "Privacy Notice"`. Extensions can subsc
Choose a stable ID owned by the extension. Register it through `OnRegisterPrivacyNotices`, or call `PrivacyNotice.CreatePrivacyNotice` during an intentional setup or upgrade path. Use that same ID for consent checks described in `privacy-notice-consent-for-external-data-transfer.md`.
-See sample: `register-integration-in-privacy-notice-registrations.good.al`.
+See sample: [`register-integration-in-privacy-notice-registrations.good.al`](register-integration-in-privacy-notice-registrations.good.al).
## Anti Pattern
diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md
index 67381f7..2febcae 100644
--- a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md
+++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md
@@ -17,10 +17,10 @@ application-area: [all]
Use the overload that takes `Verbosity`, `DataClassification`, and `TelemetryScope`. For payload-free operational telemetry that does not embed customer data, `DataClassification::SystemMetadata` is the right value. Choose `TelemetryScope::ExtensionPublisher` for telemetry meant for the publishing partner only; `TelemetryScope::All` also forwards to the customer's tenant telemetry.
-See sample: `session-logmessage-requires-dataclassification.good.al`.
+See sample: [`session-logmessage-requires-dataclassification.good.al`](session-logmessage-requires-dataclassification.good.al).
## Anti Pattern
Calling `Session.LogMessage('0003', 'Operation completed', Verbosity::Normal)` β the overload omits `DataClassification` and leaves the platform without the information needed to classify the entry. Detection signal: a `Session.LogMessage` call whose argument list ends at `Verbosity`.
-See sample: `session-logmessage-requires-dataclassification.bad.al`.
+See sample: [`session-logmessage-requires-dataclassification.bad.al`](session-logmessage-requires-dataclassification.bad.al).
diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md
index 253d2cc..f41dc11 100644
--- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md
+++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md
@@ -17,7 +17,7 @@ A valid table-level `DataClassification` is the effective default for the Normal
Use a table-level classification when it accurately describes the table's fields, and add a field-level classification only where a field stores a different kind of data. Do not flag a Normal field solely because it omits an explicit property when its own table supplies a valid default; verify whether the inherited value matches the field's data instead. A `tableextension` has no default to inherit, so require an explicit `DataClassification` on every Normal field it adds.
-See sample: `table-level-data-classification-cascades.good.al`.
+See sample: [`table-level-data-classification-cascades.good.al`](table-level-data-classification-cascades.good.al).
## Anti Pattern
diff --git a/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al
new file mode 100644
index 0000000..67012eb
--- /dev/null
+++ b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al
@@ -0,0 +1,39 @@
+query 50428 "Static Query Filter Bad"
+{
+ QueryType = Normal;
+
+ elements
+ {
+ dataitem(SalesHeader; "Sales Header")
+ {
+ DataItemTableFilter = Status = const(Open);
+
+ column(DocumentNo; "No.")
+ {
+ }
+ filter(StatusFilter; Status)
+ {
+ }
+ }
+ }
+}
+
+codeunit 50429 "Static Query Filter Bad"
+{
+ procedure ReadReleasedOrders()
+ var
+ SalesHeader: Record "Sales Header";
+ SalesHeaderQuery: Query "Static Query Filter Bad";
+ begin
+ // This is combined with Status = Open and returns no rows.
+ SalesHeaderQuery.SetRange(StatusFilter, SalesHeader.Status::Released);
+ SalesHeaderQuery.Open();
+ while SalesHeaderQuery.Read() do
+ ProcessOrder(SalesHeaderQuery.DocumentNo);
+ SalesHeaderQuery.Close();
+ end;
+
+ local procedure ProcessOrder(DocumentNo: Code[20])
+ begin
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al
new file mode 100644
index 0000000..d095330
--- /dev/null
+++ b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al
@@ -0,0 +1,38 @@
+query 50430 "Static Query Filter Good"
+{
+ QueryType = Normal;
+
+ elements
+ {
+ dataitem(SalesHeader; "Sales Header")
+ {
+ DataItemTableFilter = "Document Type" = const(Order);
+
+ column(DocumentNo; "No.")
+ {
+ }
+ filter(StatusFilter; Status)
+ {
+ }
+ }
+ }
+}
+
+codeunit 50431 "Static Query Filter Good"
+{
+ procedure ReadReleasedOrders()
+ var
+ SalesHeader: Record "Sales Header";
+ SalesHeaderQuery: Query "Static Query Filter Good";
+ begin
+ SalesHeaderQuery.SetRange(StatusFilter, SalesHeader.Status::Released);
+ SalesHeaderQuery.Open();
+ while SalesHeaderQuery.Read() do
+ ProcessOrder(SalesHeaderQuery.DocumentNo);
+ SalesHeaderQuery.Close();
+ end;
+
+ local procedure ProcessOrder(DocumentNo: Code[20])
+ begin
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.md b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.md
new file mode 100644
index 0000000..6a96db1
--- /dev/null
+++ b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: query
+keywords: [query, dataitemtablefilter, setfilter, setrange, static-filter, filter-precedence]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# DataItemTableFilter cannot be overwritten at runtime
+
+## Description
+
+`DataItemTableFilter` defines a static filter on a Query dataitem. A runtime `SetFilter` or `SetRange` on the same source field does not replace that filter. The static and runtime filters are combined with AND, so contradictory values produce an empty dataset instead of broadening or replacing the query definition.
+
+## Best Practice
+
+Keep only invariant restrictions in `DataItemTableFilter`. Expose caller-selectable fields through a column or filter row and apply their values with `SetFilter` or `SetRange` before `Open()`. When both filter types intentionally target the same field, ensure their intersection represents the required dataset.
+
+See sample: [`dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al`](dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al).
+
+## Anti Pattern
+
+Define a static filter in `DataItemTableFilter`, then apply a contradictory runtime filter to the same source field while expecting the runtime filter to replace the static one. Both filters remain effective and the query returns no rows.
+
+See sample: [`dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al`](dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al).
+
+## References
+
+Filtering in Query objects β https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-query-filters
\ No newline at end of file
diff --git a/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md
index 4bc8816..0d7ca47 100644
--- a/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md
+++ b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md
@@ -17,10 +17,10 @@ Calling `Open()` on an already open query first closes the current dataset and o
Open once for one read pass. Close after the pass, and call `Clear(QueryVariable)` before reusing the variable for a logically independent query whose filters must start empty. Set the next pass's filters explicitly before reopening.
-See sample: `reopening-query-resets-cursor-but-keeps-filters.good.al`.
+See sample: [`reopening-query-resets-cursor-but-keeps-filters.good.al`](reopening-query-resets-cursor-but-keeps-filters.good.al).
## Anti Pattern
Calling `Open()` inside or between reads to "advance" or "start fresh", or reusing the same query variable for a new operation while assuming `Open()` cleared old filters. The code compiles but can repeatedly process the first row or silently omit rows behind a retained filter.
-See sample: `reopening-query-resets-cursor-but-keeps-filters.bad.al`.
+See sample: [`reopening-query-resets-cursor-but-keeps-filters.bad.al`](reopening-query-resets-cursor-but-keeps-filters.bad.al).
diff --git a/microsoft/knowledge/query/set-query-filters-before-open.md b/microsoft/knowledge/query/set-query-filters-before-open.md
index f999456..bb82e9d 100644
--- a/microsoft/knowledge/query/set-query-filters-before-open.md
+++ b/microsoft/knowledge/query/set-query-filters-before-open.md
@@ -17,10 +17,10 @@ application-area: [all]
Apply every filter before `Open()`, then read the dataset to completion and call `Close()`. When a later branch needs different filters, close or clear the query, set the new filters, and open a new dataset deliberately.
-See sample: `set-query-filters-before-open.good.al`.
+See sample: [`set-query-filters-before-open.good.al`](set-query-filters-before-open.good.al).
## Anti Pattern
`Query.Open()` followed by `SetFilter` or `SetRange` and then `Read()` under the assumption that the filter updates the open cursor. Refiltering after `Open()` is valid only when the code intentionally opens a fresh dataset afterward.
-See sample: `set-query-filters-before-open.bad.al`.
+See sample: [`set-query-filters-before-open.bad.al`](set-query-filters-before-open.bad.al).
diff --git a/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.bad.al b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.bad.al
new file mode 100644
index 0000000..f286334
--- /dev/null
+++ b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.bad.al
@@ -0,0 +1,37 @@
+query 50432 "Column Query Filter Bad"
+{
+ QueryType = Normal;
+
+ elements
+ {
+ dataitem(SalesLine; "Sales Line")
+ {
+ column(DocumentNo; "Document No.")
+ {
+ }
+ column(LineQuantity; Quantity)
+ {
+ ColumnFilter = LineQuantity = filter(> 0);
+ }
+ }
+ }
+}
+
+codeunit 50433 "Column Query Filter Bad"
+{
+ procedure ReadSmallPositiveLines()
+ var
+ SalesLineQuery: Query "Column Query Filter Bad";
+ begin
+ // This replaces > 0, so negative quantities are also returned.
+ SalesLineQuery.SetFilter(LineQuantity, '<100');
+ SalesLineQuery.Open();
+ while SalesLineQuery.Read() do
+ ProcessLine(SalesLineQuery.DocumentNo, SalesLineQuery.LineQuantity);
+ SalesLineQuery.Close();
+ end;
+
+ local procedure ProcessLine(DocumentNo: Code[20]; Quantity: Decimal)
+ begin
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.good.al b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.good.al
new file mode 100644
index 0000000..49353e6
--- /dev/null
+++ b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.good.al
@@ -0,0 +1,38 @@
+query 50434 "Column Query Filter Good"
+{
+ QueryType = Normal;
+
+ elements
+ {
+ dataitem(SalesLine; "Sales Line")
+ {
+ DataItemTableFilter = Quantity = filter(> 0);
+
+ column(DocumentNo; "Document No.")
+ {
+ }
+ column(LineQuantity; Quantity)
+ {
+ }
+ }
+ }
+}
+
+codeunit 50435 "Column Query Filter Good"
+{
+ procedure ReadSmallPositiveLines()
+ var
+ SalesLineQuery: Query "Column Query Filter Good";
+ begin
+ // This combines with the invariant Quantity > 0 dataitem filter.
+ SalesLineQuery.SetFilter(LineQuantity, '<100');
+ SalesLineQuery.Open();
+ while SalesLineQuery.Read() do
+ ProcessLine(SalesLineQuery.DocumentNo, SalesLineQuery.LineQuantity);
+ SalesLineQuery.Close();
+ end;
+
+ local procedure ProcessLine(DocumentNo: Code[20]; Quantity: Decimal)
+ begin
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.md b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.md
new file mode 100644
index 0000000..9f9dd7a
--- /dev/null
+++ b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: query
+keywords: [query, columnfilter, setfilter, setrange, filter-precedence, runtime-filter]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# SetFilter and SetRange overwrite Query ColumnFilter
+
+## Description
+
+`ColumnFilter` on a Query column or filter row defines a dynamic filter. A runtime `SetFilter` or `SetRange` on that same column or filter row replaces the `ColumnFilter`; it does not combine the two conditions. Rows excluded by the declarative filter can therefore reappear when the runtime filter omits that restriction.
+
+## Best Practice
+
+Place invariant restrictions in `DataItemTableFilter`, which runtime filters cannot overwrite. When a `ColumnFilter` is intentionally replaceable, make each runtime `SetFilter` or `SetRange` express the complete required condition before `Open()`.
+
+See sample: [`setfilter-overwrites-query-columnfilter.good.al`](setfilter-overwrites-query-columnfilter.good.al).
+
+## Anti Pattern
+
+Apply `SetFilter` or `SetRange` to a column or filter row and rely on its existing `ColumnFilter` to remain effective. The runtime call replaces that filter and can admit rows that the query definition appeared to exclude.
+
+See sample: [`setfilter-overwrites-query-columnfilter.bad.al`](setfilter-overwrites-query-columnfilter.bad.al).
+
+## References
+
+Filtering in Query objects β https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-query-filters
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.bad.al b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.bad.al
new file mode 100644
index 0000000..736ddbf
--- /dev/null
+++ b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.bad.al
@@ -0,0 +1,16 @@
+codeunit 50102 "Run Customer Reports"
+{
+ procedure RunBlockedAndUnblockedCustomers()
+ var
+ Customer: Record Customer;
+ CustomerList: Report "Customer - List";
+ begin
+ Customer.SetRange(Blocked, Customer.Blocked::All);
+ CustomerList.SetTableView(Customer);
+ CustomerList.RunModal();
+
+ Customer.SetRange(Blocked, Customer.Blocked::" ");
+ CustomerList.SetTableView(Customer);
+ CustomerList.RunModal();
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.good.al b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.good.al
new file mode 100644
index 0000000..51e5a0e
--- /dev/null
+++ b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.good.al
@@ -0,0 +1,17 @@
+codeunit 50102 "Run Customer Reports"
+{
+ procedure RunBlockedAndUnblockedCustomers()
+ var
+ Customer: Record Customer;
+ CustomerList: Report "Customer - List";
+ begin
+ Customer.SetRange(Blocked, Customer.Blocked::All);
+ CustomerList.SetTableView(Customer);
+ CustomerList.RunModal();
+
+ Clear(CustomerList);
+ Customer.SetRange(Blocked, Customer.Blocked::" ");
+ CustomerList.SetTableView(Customer);
+ CustomerList.RunModal();
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.md b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.md
new file mode 100644
index 0000000..b82c1fb
--- /dev/null
+++ b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.md
@@ -0,0 +1,32 @@
+---
+bc-version: [all]
+domain: reporting
+keywords: [report, runmodal, clear, settableview, instance, state, filters]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Clear a Report variable before an independent RunModal execution
+
+## Description
+
+`Report.Run()` automatically clears the report variable after execution, but `Report.RunModal()` does not. Reconfiguring and running the same variable for an independent operation can therefore retain filters and other instance state from the previous run.
+
+## Best Practice
+
+Call `Clear(ReportVariable)` before configuring a new, logically independent `RunModal()` execution on a reused report variable. No clear is required after a single execution, and retaining state is valid when the subsequent run intentionally continues with the same configuration.
+
+See sample: [`clear-report-variable-before-independent-runmodal.good.al`](clear-report-variable-before-independent-runmodal.good.al).
+
+## Anti Pattern
+
+Run the same report variable modally for two independent views without clearing it between runs. The second `SetTableView` can only narrow the existing report view, so filters retained by the instance can make the second result incomplete or empty.
+
+See sample: [`clear-report-variable-before-independent-runmodal.bad.al`](clear-report-variable-before-independent-runmodal.bad.al).
+
+## References
+
+`Report.RunModal()` method β https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-runmodal-method
+
+`Report.Run()` method β https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-run-method
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.bad.al b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.bad.al
new file mode 100644
index 0000000..dd88abc
--- /dev/null
+++ b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.bad.al
@@ -0,0 +1,31 @@
+report 50105 "Customer Entry Review"
+{
+ ProcessingOnly = true;
+
+ dataset
+ {
+ dataitem(Customer; Customer)
+ {
+ trigger OnAfterGetRecord()
+ var
+ EntryNo: Integer;
+ begin
+ repeat
+ EntryNo += 1;
+ if EntryNo = 5 then
+ CurrReport.Break();
+ until EntryNo = 10;
+
+ MarkCustomerReviewed();
+ end;
+ }
+ }
+
+ local procedure MarkCustomerReviewed()
+ begin
+ ReviewedCustomerCount += 1;
+ end;
+
+ var
+ ReviewedCustomerCount: Integer;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.good.al b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.good.al
new file mode 100644
index 0000000..c220732
--- /dev/null
+++ b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.good.al
@@ -0,0 +1,31 @@
+report 50105 "Customer Entry Review"
+{
+ ProcessingOnly = true;
+
+ dataset
+ {
+ dataitem(Customer; Customer)
+ {
+ trigger OnAfterGetRecord()
+ var
+ EntryNo: Integer;
+ StopReview: Boolean;
+ begin
+ repeat
+ EntryNo += 1;
+ StopReview := EntryNo = 5;
+ until StopReview or (EntryNo = 10);
+
+ MarkCustomerReviewed();
+ end;
+ }
+ }
+
+ local procedure MarkCustomerReviewed()
+ begin
+ ReviewedCustomerCount += 1;
+ end;
+
+ var
+ ReviewedCustomerCount: Integer;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.md b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.md
new file mode 100644
index 0000000..ec22ea4
--- /dev/null
+++ b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: reporting
+keywords: [report, currreport, break, loop, trigger, control-flow]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# CurrReport.Break ends the current trigger
+
+## Description
+
+`CurrReport.Break()` inside a report dataitem trigger does more than leave an AL loop. It terminates the current trigger and omits the current record from the dataset. The report runtime still invokes the remaining triggers for that record. Consequently, statements after the loop in the current trigger do not run, while later report triggers can still produce side effects.
+
+## Best Practice
+
+Use an explicit loop condition or the AL `break` statement when only the loop must end and the current trigger must continue. Use `CurrReport.Break()` only when ending the trigger and omitting the current record are both intended, and keep subsequent report triggers safe for that omitted record.
+
+See sample: [`currreport-break-ends-the-current-trigger.good.al`](currreport-break-ends-the-current-trigger.good.al).
+
+## Anti Pattern
+
+Call `CurrReport.Break()` inside a loop and rely on statements after the loop to finish processing the current record. Those statements are unreachable when the call executes, the record is omitted, and remaining report triggers still run.
+
+See sample: [`currreport-break-ends-the-current-trigger.bad.al`](currreport-break-ends-the-current-trigger.bad.al).
+
+## References
+
+`Report.Break()` method β https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-break-method
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.bad.al b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.bad.al
new file mode 100644
index 0000000..262ca78
--- /dev/null
+++ b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.bad.al
@@ -0,0 +1,27 @@
+report 50101 "Update Customer Review"
+{
+ ProcessingOnly = true;
+
+ dataset
+ {
+ dataitem(Customer; Customer)
+ {
+ trigger OnAfterGetRecord()
+ begin
+ "Last Date Modified" := Today();
+ Modify();
+
+ if Blocked <> Blocked::" " then
+ CurrReport.Quit();
+ end;
+ }
+ }
+
+ trigger OnPostReport()
+ begin
+ Message(CompletedMsg);
+ end;
+
+ var
+ CompletedMsg: Label 'Customer review completed.';
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.good.al b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.good.al
new file mode 100644
index 0000000..f7a50c5
--- /dev/null
+++ b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.good.al
@@ -0,0 +1,28 @@
+report 50101 "Update Customer Review"
+{
+ ProcessingOnly = true;
+
+ dataset
+ {
+ dataitem(Customer; Customer)
+ {
+ trigger OnAfterGetRecord()
+ begin
+ if Blocked <> Blocked::" " then
+ Error(BlockedCustomerErr, "No.");
+
+ "Last Date Modified" := Today();
+ Modify();
+ end;
+ }
+ }
+
+ trigger OnPostReport()
+ begin
+ Message(CompletedMsg);
+ end;
+
+ var
+ BlockedCustomerErr: Label 'Customer %1 is blocked.', Comment = '%1 = customer number';
+ CompletedMsg: Label 'Customer review completed.';
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.md b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.md
new file mode 100644
index 0000000..287f625
--- /dev/null
+++ b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: reporting
+keywords: [report, currreport, quit, rollback, onpostreport, transaction, control-flow]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# CurrReport.Quit rolls back report changes and skips OnPostReport
+
+## Description
+
+`CurrReport.Quit()` aborts the report without committing database changes made during its execution. It also prevents `OnPostReport` from running. It is therefore not a normal early-return mechanism for a processing report that expects earlier writes or finalization in `OnPostReport` to survive.
+
+## Best Practice
+
+Use `CurrReport.Quit()` only when silently aborting the report, rolling back its database changes, and skipping `OnPostReport` are all intentional. When processing must stop with a failure, raise an error. When completed work and `OnPostReport` must be preserved, structure the dataitem control flow without `Quit()`.
+
+See sample: [`currreport-quit-rolls-back-and-skips-onpostreport.good.al`](currreport-quit-rolls-back-and-skips-onpostreport.good.al).
+
+## Anti Pattern
+
+Modify data and then call `CurrReport.Quit()` while relying on those writes or on `OnPostReport` finalization. The report exits without committing its changes and never invokes `OnPostReport`.
+
+See sample: [`currreport-quit-rolls-back-and-skips-onpostreport.bad.al`](currreport-quit-rolls-back-and-skips-onpostreport.bad.al).
+
+## References
+
+`Report.Quit()` method β https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-quit-method
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.bad.al b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.bad.al
new file mode 100644
index 0000000..1debd99
--- /dev/null
+++ b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.bad.al
@@ -0,0 +1,26 @@
+report 50100 "Released Customer List"
+{
+ ProcessingOnly = true;
+
+ dataset
+ {
+ dataitem(Customer; Customer)
+ {
+ trigger OnAfterGetRecord()
+ begin
+ if Blocked <> Blocked::" " then
+ CurrReport.Skip();
+
+ CountIncludedCustomer();
+ end;
+ }
+ }
+
+ local procedure CountIncludedCustomer()
+ begin
+ IncludedCustomerCount += 1;
+ end;
+
+ var
+ IncludedCustomerCount: Integer;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.good.al b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.good.al
new file mode 100644
index 0000000..f239a2b
--- /dev/null
+++ b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.good.al
@@ -0,0 +1,28 @@
+report 50100 "Released Customer List"
+{
+ ProcessingOnly = true;
+
+ dataset
+ {
+ dataitem(Customer; Customer)
+ {
+ trigger OnAfterGetRecord()
+ begin
+ if Blocked <> Blocked::" " then begin
+ CurrReport.Skip();
+ exit;
+ end;
+
+ CountIncludedCustomer();
+ end;
+ }
+ }
+
+ local procedure CountIncludedCustomer()
+ begin
+ IncludedCustomerCount += 1;
+ end;
+
+ var
+ IncludedCustomerCount: Integer;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.md b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.md
new file mode 100644
index 0000000..d2b4e34
--- /dev/null
+++ b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: reporting
+keywords: [report, currreport, skip, trigger, onaftergetrecord, control-flow]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# CurrReport.Skip omits the record but does not stop trigger code
+
+## Description
+
+`CurrReport.Skip()` omits the current record from the report dataset and continues processing with the next record. It does not terminate the current trigger, and the remaining triggers for the current record still run. Code placed after `Skip()` can therefore produce side effects for a record that never appears in the output.
+
+## Best Practice
+
+When no further code in the current trigger should run for a skipped record, call `CurrReport.Skip()` and then exit the trigger explicitly. Keep later record triggers safe for skipped records because the report runtime still invokes them.
+
+See sample: [`currreport-skip-does-not-stop-trigger-code.good.al`](currreport-skip-does-not-stop-trigger-code.good.al).
+
+## Anti Pattern
+
+Call `CurrReport.Skip()` and rely on it to bypass subsequent statements or later record triggers. The record is removed from the dataset, but those statements and triggers can still update state, write data, or perform expensive work.
+
+See sample: [`currreport-skip-does-not-stop-trigger-code.bad.al`](currreport-skip-does-not-stop-trigger-code.bad.al).
+
+## References
+
+`Report.Skip()` method β https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-skip-method
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.bad.al b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.bad.al
new file mode 100644
index 0000000..945c0ae
--- /dev/null
+++ b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.bad.al
@@ -0,0 +1,14 @@
+codeunit 50106 "Download Customer Reports"
+{
+ procedure DownloadReports(var Customer: Record Customer)
+ var
+ CustomerView: Record Customer;
+ begin
+ if Customer.FindSet() then
+ repeat
+ CustomerView := Customer;
+ CustomerView.SetRecFilter();
+ Report.Run(Report::"Customer - List", false, false, CustomerView);
+ until Customer.Next() = 0;
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.good.al b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.good.al
new file mode 100644
index 0000000..dea2e7f
--- /dev/null
+++ b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.good.al
@@ -0,0 +1,37 @@
+codeunit 50106 "Download Customer Reports"
+{
+ procedure DownloadReports(var Customer: Record Customer)
+ var
+ CustomerView: Record Customer;
+ CustomerList: Report "Customer - List";
+ DataCompression: Codeunit "Data Compression";
+ ReportTempBlob: Codeunit "Temp Blob";
+ ZipTempBlob: Codeunit "Temp Blob";
+ ReportInStream: InStream;
+ ZipInStream: InStream;
+ ReportOutStream: OutStream;
+ ZipOutStream: OutStream;
+ ZipFileName: Text;
+ begin
+ DataCompression.CreateZipArchive();
+ if Customer.FindSet() then
+ repeat
+ Clear(CustomerList);
+ Clear(ReportTempBlob);
+ CustomerView := Customer;
+ CustomerView.SetRecFilter();
+ CustomerList.SetTableView(CustomerView);
+ ReportTempBlob.CreateOutStream(ReportOutStream);
+ CustomerList.SaveAs('', ReportFormat::Pdf, ReportOutStream);
+ ReportTempBlob.CreateInStream(ReportInStream);
+ DataCompression.AddEntry(ReportInStream, Customer."No." + '.pdf');
+ until Customer.Next() = 0;
+
+ ZipTempBlob.CreateOutStream(ZipOutStream);
+ DataCompression.SaveZipArchive(ZipOutStream);
+ DataCompression.CloseZipArchive();
+ ZipTempBlob.CreateInStream(ZipInStream);
+ ZipFileName := 'CustomerReports.zip';
+ DownloadFromStream(ZipInStream, '', '', '*.zip', ZipFileName);
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.md b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.md
new file mode 100644
index 0000000..8aede8b
--- /dev/null
+++ b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.md
@@ -0,0 +1,32 @@
+---
+bc-version: [all]
+domain: reporting
+keywords: [report, run, saveas, downloadfromstream, web-client, loop, zip, data-compression]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Report output in a loop needs one client download
+
+## Description
+
+The Business Central Web client can deliver only one file per request. When AL generates or downloads a report file repeatedly in the same request, only the last file is delivered to the browser. Earlier report output is silently unavailable to the user even though every iteration ran.
+
+## Best Practice
+
+Generate each report into a stream, add the streams to one archive, and call `DownloadFromStream` once after the loop. A direct report run or download inside a loop is valid only when the execution context does not use the Web client or the loop is guaranteed to execute at most once.
+
+See sample: [`report-output-in-a-loop-needs-one-client-download.good.al`](report-output-in-a-loop-needs-one-client-download.good.al).
+
+## Anti Pattern
+
+Call `Report.Run`, `Report.RunModal`, or `DownloadFromStream` repeatedly in a loop initiated by one Web client action and expect every generated file to reach the browser. The client receives only the last download.
+
+See sample: [`report-output-in-a-loop-needs-one-client-download.bad.al`](report-output-in-a-loop-needs-one-client-download.bad.al).
+
+## References
+
+`File.DownloadFromStream` method β https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/file/file-downloadfromstream-method
+
+`Data Compression` codeunit β https://learn.microsoft.com/dynamics365/business-central/application/system-application/codeunit/system.io.data-compression
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.bad.al b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.bad.al
new file mode 100644
index 0000000..7a25a12
--- /dev/null
+++ b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.bad.al
@@ -0,0 +1,30 @@
+report 50103 "Base Customer Export"
+{
+ ProcessingOnly = true;
+
+ dataset
+ {
+ dataitem(Customer; Customer)
+ {
+ trigger OnPreDataItem()
+ begin
+ SetRange(Blocked, Blocked::" ");
+ SetRange("Country/Region Code");
+ end;
+ }
+ }
+}
+
+reportextension 50104 "Local Customer Export" extends "Base Customer Export"
+{
+ dataset
+ {
+ modify(Customer)
+ {
+ trigger OnBeforePreDataItem()
+ begin
+ SetFilter("Country/Region Code", '<>%1', '');
+ end;
+ }
+ }
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.good.al b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.good.al
new file mode 100644
index 0000000..52c3ebe
--- /dev/null
+++ b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.good.al
@@ -0,0 +1,30 @@
+report 50103 "Base Customer Export"
+{
+ ProcessingOnly = true;
+
+ dataset
+ {
+ dataitem(Customer; Customer)
+ {
+ trigger OnPreDataItem()
+ begin
+ SetRange(Blocked, Blocked::" ");
+ SetRange("Country/Region Code");
+ end;
+ }
+ }
+}
+
+reportextension 50104 "Local Customer Export" extends "Base Customer Export"
+{
+ dataset
+ {
+ modify(Customer)
+ {
+ trigger OnAfterPreDataItem()
+ begin
+ SetFilter("Country/Region Code", '<>%1', '');
+ end;
+ }
+ }
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.md b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.md
new file mode 100644
index 0000000..c149748
--- /dev/null
+++ b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.md
@@ -0,0 +1,32 @@
+---
+bc-version: [19..]
+domain: reporting
+keywords: [reportextension, report, dataitem, trigger-order, onbeforepredataitem, onafterpredataitem, onbeforeaftergetrecord, onafteraftergetrecord]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Choose ReportExtension dataitem triggers by their order around the base trigger
+
+## Description
+
+ReportExtension dataitem triggers run at defined points around the corresponding base-report trigger. `OnBeforePreDataItem` and `OnBeforeAfterGetRecord` run before the base trigger; `OnAfterPreDataItem` and `OnAfterAfterGetRecord` run after it. A filter or calculated value can be overwritten when an extension uses a before-trigger even though its result must be final after base processing.
+
+## Best Practice
+
+Choose the before or after trigger from the required ordering relative to base behavior. Use an after-trigger when the extension must observe or refine the final view or value produced by the base trigger. A before-trigger is valid when the base report must consume the extension's state.
+
+See sample: [`reportextension-dataitem-trigger-order-is-explicit.good.al`](reportextension-dataitem-trigger-order-is-explicit.good.al).
+
+## Anti Pattern
+
+Place extension logic in a before-trigger while relying on its filter or value to survive a base trigger that can replace it. Do not report a before-trigger merely because an after-trigger exists; the defect requires visible base behavior or another reliable source showing that ordering changes the result.
+
+See sample: [`reportextension-dataitem-trigger-order-is-explicit.bad.al`](reportextension-dataitem-trigger-order-is-explicit.bad.al).
+
+## References
+
+`OnBeforePreDataItem` report-extension trigger β https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/reportextensiondatasetmodify/devenv-onbeforepredataitem-reportextensiondatasetmodify-trigger
+
+`OnAfterPreDataItem` report-extension trigger β https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/reportextensiondatasetmodify/devenv-onafterpredataitem-reportextensiondatasetmodify-trigger
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.bad.al b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.bad.al
new file mode 100644
index 0000000..d932f91
--- /dev/null
+++ b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.bad.al
@@ -0,0 +1,31 @@
+report 50110 "Customer Export"
+{
+ ProcessingOnly = true;
+
+ dataset
+ {
+ dataitem(Customer; Customer)
+ {
+ }
+ }
+
+ trigger OnPreReport()
+ var
+ ExportSetup: Record "Customer Export Setup";
+ begin
+ ExportSetup.Get();
+ ExportSetup.TestField("Export Date");
+ end;
+}
+
+reportextension 50111 "Customer Export Extension" extends "Customer Export"
+{
+ trigger OnPreReport()
+ var
+ ExportSetup: Record "Customer Export Setup";
+ begin
+ ExportSetup.Get();
+ ExportSetup.Validate("Export Date", Today());
+ ExportSetup.Modify(true);
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.good.al b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.good.al
new file mode 100644
index 0000000..293784b
--- /dev/null
+++ b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.good.al
@@ -0,0 +1,37 @@
+report 50110 "Customer Export"
+{
+ ProcessingOnly = true;
+
+ dataset
+ {
+ dataitem(Customer; Customer)
+ {
+ }
+ }
+
+ trigger OnPreReport()
+ var
+ ExportDate: Date;
+ begin
+ OnBeforeResolveExportDate(ExportDate);
+ if ExportDate = 0D then
+ Error(ExportDateRequiredErr);
+ end;
+
+ [IntegrationEvent(false, false)]
+ local procedure OnBeforeResolveExportDate(var ExportDate: Date)
+ begin
+ end;
+
+ var
+ ExportDateRequiredErr: Label 'An export date is required.';
+}
+
+codeunit 50111 "Customer Export Extension"
+{
+ [EventSubscriber(ObjectType::Report, Report::"Customer Export", 'OnBeforeResolveExportDate', '', false, false)]
+ local procedure SetExportDate(var ExportDate: Date)
+ begin
+ ExportDate := Today();
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.md b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.md
new file mode 100644
index 0000000..414752a
--- /dev/null
+++ b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.md
@@ -0,0 +1,30 @@
+---
+bc-version: [18..]
+domain: reporting
+keywords: [reportextension, report, trigger-order, onprereport, onpostreport, base-report, integration-event]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# ReportExtension report triggers run after base report triggers
+
+## Description
+
+`OnPreReport` and `OnPostReport` on a ReportExtension run after the corresponding triggers on the base report. An extension `OnPreReport` cannot prepare state that the base `OnPreReport` must consume, and an extension `OnPostReport` cannot affect finalization that the base `OnPostReport` has already completed.
+
+## Best Practice
+
+Use a base-report event at the required execution point when extension logic must run before or within a base trigger. Use ReportExtension `OnPreReport` and `OnPostReport` only for work that is correct after the corresponding base trigger. Report a violation only when the base trigger and extension dependency are both visible or otherwise established.
+
+See sample: [`reportextension-report-triggers-run-after-base-triggers.good.al`](reportextension-report-triggers-run-after-base-triggers.good.al).
+
+## Anti Pattern
+
+Initialize data in a ReportExtension `OnPreReport` and rely on the base report's `OnPreReport` to consume it, or perform extension `OnPostReport` work that the base `OnPostReport` needed beforehand. The base trigger has already run.
+
+See sample: [`reportextension-report-triggers-run-after-base-triggers.bad.al`](reportextension-report-triggers-run-after-base-triggers.bad.al).
+
+## References
+
+Report extension object β https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-report-ext-object
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.bad.al b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.bad.al
new file mode 100644
index 0000000..844c542
--- /dev/null
+++ b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.bad.al
@@ -0,0 +1,26 @@
+report 50107 "Selected Sales Orders"
+{
+ ProcessingOnly = true;
+
+ dataset
+ {
+ dataitem(SalesHeader; "Sales Header")
+ {
+ DataItemTableView = where("Document Type" = const(Order), Status = const(Open));
+ }
+ }
+}
+
+codeunit 50108 "Run Selected Sales Orders"
+{
+ procedure RunReleasedOrders()
+ var
+ SalesHeader: Record "Sales Header";
+ SelectedSalesOrders: Report "Selected Sales Orders";
+ begin
+ SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
+ SalesHeader.SetRange(Status, SalesHeader.Status::Released);
+ SelectedSalesOrders.SetTableView(SalesHeader);
+ SelectedSalesOrders.RunModal();
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.good.al b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.good.al
new file mode 100644
index 0000000..5dc4f2e
--- /dev/null
+++ b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.good.al
@@ -0,0 +1,26 @@
+report 50107 "Selected Sales Orders"
+{
+ ProcessingOnly = true;
+
+ dataset
+ {
+ dataitem(SalesHeader; "Sales Header")
+ {
+ DataItemTableView = where("Document Type" = const(Order));
+ }
+ }
+}
+
+codeunit 50108 "Run Selected Sales Orders"
+{
+ procedure RunReleasedOrders()
+ var
+ SalesHeader: Record "Sales Header";
+ SelectedSalesOrders: Report "Selected Sales Orders";
+ begin
+ SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
+ SalesHeader.SetRange(Status, SalesHeader.Status::Released);
+ SelectedSalesOrders.SetTableView(SalesHeader);
+ SelectedSalesOrders.RunModal();
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.md b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.md
new file mode 100644
index 0000000..2dfc691
--- /dev/null
+++ b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: reporting
+keywords: [report, settableview, dataitemtableview, filter, view, narrowing]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# SetTableView cannot broaden DataItemTableView
+
+## Description
+
+`Report.SetTableView()` applies the supplied record view by narrowing the view already defined by the report dataitem's `DataItemTableView`. It cannot remove or broaden a static dataitem filter. A caller that requests records excluded by `DataItemTableView` therefore produces an empty dataset rather than overriding the report filter.
+
+## Best Practice
+
+Keep only invariant restrictions in `DataItemTableView`. When callers must select among values, leave that dimension open in the static view and pass the required filter through `SetTableView`. Review this as a defect only when the report definition and caller together show a contradictory filter.
+
+See sample: [`settableview-cannot-broaden-dataitemtableview.good.al`](settableview-cannot-broaden-dataitemtableview.good.al).
+
+## Anti Pattern
+
+Define a static filter in `DataItemTableView` and call `SetTableView` with a mutually exclusive filter while expecting the runtime view to replace the static one. The filters are intersected and no records are selected.
+
+See sample: [`settableview-cannot-broaden-dataitemtableview.bad.al`](settableview-cannot-broaden-dataitemtableview.bad.al).
+
+## References
+
+`Report.SetTableView()` method β https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-settableview-method
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.bad.al b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.bad.al
new file mode 100644
index 0000000..feccfb6
--- /dev/null
+++ b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.bad.al
@@ -0,0 +1,17 @@
+codeunit 50109 "Export Customer Report"
+{
+ procedure ExportReport()
+ var
+ TempBlob: Codeunit "Temp Blob";
+ ReportOutStream: OutStream;
+ RequestPageParameters: Text;
+ begin
+ RequestPageParameters := Report.RunRequestPage(Report::"Customer - List");
+ TempBlob.CreateOutStream(ReportOutStream);
+ Report.SaveAs(
+ Report::"Customer - List",
+ RequestPageParameters,
+ ReportFormat::Pdf,
+ ReportOutStream);
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.good.al b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.good.al
new file mode 100644
index 0000000..d706a2f
--- /dev/null
+++ b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.good.al
@@ -0,0 +1,20 @@
+codeunit 50109 "Export Customer Report"
+{
+ procedure ExportReport()
+ var
+ TempBlob: Codeunit "Temp Blob";
+ ReportOutStream: OutStream;
+ RequestPageParameters: Text;
+ begin
+ RequestPageParameters := Report.RunRequestPage(Report::"Customer - List");
+ if RequestPageParameters = '' then
+ exit;
+
+ TempBlob.CreateOutStream(ReportOutStream);
+ Report.SaveAs(
+ Report::"Customer - List",
+ RequestPageParameters,
+ ReportFormat::Pdf,
+ ReportOutStream);
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.md b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.md
new file mode 100644
index 0000000..0cc0e71
--- /dev/null
+++ b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: reporting
+keywords: [report, runrequestpage, cancel, parameters, saveas, execute, print]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Stop when RunRequestPage returns empty parameters
+
+## Description
+
+`Report.RunRequestPage()` returns an empty string when the user chooses **Cancel**. Passing that value to `Report.Execute`, `Report.Print`, or `Report.SaveAs` ignores the cancellation and can run the report with default parameters instead.
+
+## Best Practice
+
+Test the returned parameter string immediately after `RunRequestPage()` and exit when it is empty. Pass the value to `Execute`, `Print`, or `SaveAs` only after the user has confirmed the request page.
+
+See sample: [`stop-when-runrequestpage-returns-empty-parameters.good.al`](stop-when-runrequestpage-returns-empty-parameters.good.al).
+
+## Anti Pattern
+
+Call `RunRequestPage()` and unconditionally pass its return value to a report execution method. Choosing **Cancel** can still execute, print, or save the report.
+
+See sample: [`stop-when-runrequestpage-returns-empty-parameters.bad.al`](stop-when-runrequestpage-returns-empty-parameters.bad.al).
+
+## References
+
+`Report.RunRequestPage()` method β https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/report-runrequestpage-method
\ No newline at end of file
diff --git a/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.bad.al b/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.bad.al
new file mode 100644
index 0000000..248f3a4
--- /dev/null
+++ b/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.bad.al
@@ -0,0 +1,13 @@
+codeunit 50106 "SCM Cancel Reservation Bad"
+{
+ procedure CancelSalesReservation(ReservationEntryNo: Integer)
+ var
+ ReservationEntry: Record "Reservation Entry";
+ begin
+ ReservationEntry.Get(ReservationEntryNo, false);
+ ReservationEntry.TestField("Source Type", Database::"Sales Line");
+ ReservationEntry.TestField("Reservation Status", ReservationEntry."Reservation Status"::Reservation);
+
+ ReservationEntry.Delete(true);
+ end;
+}
diff --git a/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.good.al b/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.good.al
new file mode 100644
index 0000000..3d5849d
--- /dev/null
+++ b/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.good.al
@@ -0,0 +1,14 @@
+codeunit 50107 "SCM Cancel Reservation Good"
+{
+ procedure CancelSalesReservation(ReservationEntryNo: Integer)
+ var
+ ReservationEntry: Record "Reservation Entry";
+ ReservationEngineMgt: Codeunit "Reservation Engine Mgt.";
+ begin
+ ReservationEntry.Get(ReservationEntryNo, false);
+ ReservationEntry.TestField("Source Type", Database::"Sales Line");
+ ReservationEntry.TestField("Reservation Status", ReservationEntry."Reservation Status"::Reservation);
+
+ ReservationEngineMgt.CancelReservation(ReservationEntry);
+ end;
+}
diff --git a/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.md b/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.md
new file mode 100644
index 0000000..1df3b12
--- /dev/null
+++ b/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.md
@@ -0,0 +1,38 @@
+---
+bc-version: [all]
+domain: scm
+keywords: [reservation-entry, cancelreservation, reservation-engine-mgt, reservation-status, order-tracking, disallow-cancellation]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Cancel reservations through reservation management
+
+## Description
+
+Persistent `"Reservation Entry"` rows are not disposable allocation markers. Reservation and Tracking links use an entry-number pair with opposite `Positive` values, while Surplus and Prospect entries can legitimately be unpaired. Cancelling a binding reservation must handle its counterpart and any remaining item tracking or order tracking, not just remove a row.
+
+## Best Practice
+
+Explicit cancellation of an existing binding reservation uses `"Reservation Engine Mgt.".CancelReservation`. It checks the reservation status and `"Disallow Cancellation"`, handles the counterpart, and preserves or retracks the remaining source quantities as appropriate. Source-line quantity changes have their own source-specific reservation management path.
+
+Not every Reservation Entry has a partner or identical lot/serial values on both sides: Surplus/Prospect entries and supported late-binding scenarios have different relationships. Temporary buffers, engine-owned updates, and supported publisher metadata are not independent cancellation. Cancelling a reservation is also different from intentionally removing an item-tracking assignment.
+
+The samples retrieve the negative side of a persistent sales-line reservation and cancel only the binding. They do not delete the sales line or remove its tracking specifications.
+
+See sample: [`cancel-reservations-through-reservation-management.good.al`](cancel-reservations-through-reservation-management.good.al).
+
+## Anti Pattern
+
+`Delete(true)`, `DeleteAll`, or a status/source rewrite on persistent `"Reservation Entry"` records does not perform binding-reservation cancellation. Even deleting both sides can discard tracking that should survive and omit retracking.
+
+Normal processing of temporary Prospect/Surplus buffers is outside that cancellation workflow, and an unpaired row is not intrinsically an orphan.
+
+See sample: [`cancel-reservations-through-reservation-management.bad.al`](cancel-reservations-through-reservation-management.bad.al).
+
+## References
+
+- [Reservation, order tracking, and action messaging](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-reservation-order-tracking-and-action-messaging)
+- [Item tracking and reservations](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-item-tracking-and-reservations)
+- [BaseApp reservation cancellation](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Tracking/ReservationEngineMgt.Codeunit.al#L51-L90)
diff --git a/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.bad.al b/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.bad.al
new file mode 100644
index 0000000..7bc4df8
--- /dev/null
+++ b/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.bad.al
@@ -0,0 +1,47 @@
+codeunit 50116 "SCM Requisition Action Bad"
+{
+ procedure CarryOutAcceptedNewPurchase(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer; OrderDate: Date; PostingDate: Date; ReceiptDate: Date; CutoffDate: Date)
+ var
+ RequisitionLine: Record "Requisition Line";
+ PurchaseHeader: Record "Purchase Header";
+ PurchaseLine: Record "Purchase Line";
+ begin
+ if (OrderDate = 0D) or (PostingDate = 0D) or (ReceiptDate = 0D) or (CutoffDate = 0D) then
+ Error(PlanningDatesErr);
+ RequisitionLine.Get(TemplateName, BatchName, LineNo);
+ RequisitionLine.TestField(Type, RequisitionLine.Type::Item);
+ RequisitionLine.TestField("Replenishment System", RequisitionLine."Replenishment System"::Purchase);
+ RequisitionLine.TestField("Action Message", RequisitionLine."Action Message"::New);
+ RequisitionLine.TestField("Accept Action Message", true);
+ RequisitionLine.TestField("Demand Type", Database::"Sales Line");
+ RequisitionLine.TestField("Demand Order No.");
+ RequisitionLine.TestField("Vendor No.");
+ RequisitionLine.SetRecFilter();
+
+ PurchaseHeader.Init();
+ PurchaseHeader."Document Type" := PurchaseHeader."Document Type"::Order;
+ PurchaseHeader.Insert(true);
+ PurchaseHeader.Validate("Buy-from Vendor No.", RequisitionLine."Vendor No.");
+ PurchaseHeader.Validate("Order Date", OrderDate);
+ PurchaseHeader.Validate("Posting Date", PostingDate);
+ PurchaseHeader.Validate("Expected Receipt Date", ReceiptDate);
+ PurchaseHeader.Modify(true);
+
+ PurchaseLine.Init();
+ PurchaseLine."Document Type" := PurchaseHeader."Document Type";
+ PurchaseLine."Document No." := PurchaseHeader."No.";
+ PurchaseLine."Line No." := 10000;
+ PurchaseLine.Validate(Type, PurchaseLine.Type::Item);
+ PurchaseLine.Validate("No.", RequisitionLine."No.");
+ PurchaseLine.Validate("Location Code", RequisitionLine."Location Code");
+ PurchaseLine.Validate("Variant Code", RequisitionLine."Variant Code");
+ PurchaseLine.Validate("Unit of Measure Code", RequisitionLine."Unit of Measure Code");
+ PurchaseLine.Validate(Quantity, RequisitionLine.Quantity);
+ PurchaseLine.Insert(true);
+
+ RequisitionLine.Delete(true);
+ end;
+
+ var
+ PlanningDatesErr: Label 'Supply explicit order, posting, receipt, and cutoff dates.';
+}
diff --git a/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.good.al b/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.good.al
new file mode 100644
index 0000000..c09ea65
--- /dev/null
+++ b/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.good.al
@@ -0,0 +1,31 @@
+codeunit 50117 "SCM Requisition Action Good"
+{
+ procedure CarryOutAcceptedNewPurchase(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer; OrderDate: Date; PostingDate: Date; ReceiptDate: Date; CutoffDate: Date)
+ var
+ RequisitionLine: Record "Requisition Line";
+ PurchaseHeaderDefaults: Record "Purchase Header";
+ ReqWkshMakeOrder: Codeunit "Req. Wksh.-Make Order";
+ begin
+ if (OrderDate = 0D) or (PostingDate = 0D) or (ReceiptDate = 0D) or (CutoffDate = 0D) then
+ Error(PlanningDatesErr);
+ RequisitionLine.Get(TemplateName, BatchName, LineNo);
+ RequisitionLine.TestField(Type, RequisitionLine.Type::Item);
+ RequisitionLine.TestField("Replenishment System", RequisitionLine."Replenishment System"::Purchase);
+ RequisitionLine.TestField("Action Message", RequisitionLine."Action Message"::New);
+ RequisitionLine.TestField("Accept Action Message", true);
+ RequisitionLine.TestField("Demand Type", Database::"Sales Line");
+ RequisitionLine.TestField("Demand Order No.");
+ RequisitionLine.TestField("Vendor No.");
+ RequisitionLine.SetRecFilter();
+
+ PurchaseHeaderDefaults."Order Date" := OrderDate;
+ PurchaseHeaderDefaults."Posting Date" := PostingDate;
+ PurchaseHeaderDefaults."Expected Receipt Date" := ReceiptDate;
+ ReqWkshMakeOrder.Set(PurchaseHeaderDefaults, CutoffDate, false);
+ ReqWkshMakeOrder.SetSuppressCommit(true);
+ ReqWkshMakeOrder.CarryOutBatchAction(RequisitionLine);
+ end;
+
+ var
+ PlanningDatesErr: Label 'Supply explicit order, posting, receipt, and cutoff dates.';
+}
diff --git a/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.md b/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.md
new file mode 100644
index 0000000..88f68bd
--- /dev/null
+++ b/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.md
@@ -0,0 +1,43 @@
+---
+bc-version: [all]
+domain: scm
+keywords: [requisition-line, action-message, accept-action-message, req-wksh-make-order, carryoutbatchaction, demand-order-no, planning-flexibility]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Carry out requisition actions through the standard workflow
+
+## Description
+
+A requisition/planning line is a pending change to a supply/demand network, not just a template for a purchase line. Carry-out interprets New, change-quantity, reschedule, and cancel actions, preserves referenced supply and planning flexibility, and moves reservation/tracking ownership before finalizing the proposal. Creating a plausible purchase order and deleting the requisition line can leave new supply unrelated to the demand that caused it.
+
+## Best Practice
+
+Requisition batch carry-out initializes `"Req. Wksh.-Make Order"` with `Set` and invokes `CarryOutBatchAction` on the intended accepted lines. Order/posting/receipt defaults are separate from the ending-order-date cutoff, and worksheet/batch/line filters define the selection. A plain `Run` or a single order-line insertion helper is not a replacement for this batch initialization and finalization.
+
+The standard `"Carry Out Action"` dispatch handles broader planning output and its configured purchase, transfer, assembly, or manufacturing choices. Each action's supply change and source-specific reservation transfer precede proposal finalization; not every action creates a new purchase order.
+
+Ordinary manual purchase creation that does not consume planning output is outside this rule. Users may reject or delete unwanted proposals without creating supply; temporary planning simulations, pre-carry-out enrichment, and engine-owned cleanup are also legitimate. `Delete(true)` on a requisition line is not intrinsically a defect.
+
+The samples select an existing accepted New/Purchase item proposal with sales-demand context. Dates are explicit, the source selection remains bounded, and the clean sample leaves order creation and reservation handoff to the standard workflow; it is not a complete planning-run generator.
+
+See sample: [`carry-out-requisition-actions-through-the-standard-workflow.good.al`](carry-out-requisition-actions-through-the-standard-workflow.good.al).
+
+## Anti Pattern
+
+Manually creating or changing supply from a subset of an accepted persistent `"Requisition Line"`, then deleting or marking that proposal handled, skips the standard carry-out/source-reservation handoff. Purchase-field validation and the requisition delete trigger do not first move the proposal's demand links to the new purchase line.
+
+Deleting an unwanted suggestion or creating an ordinary purchase order without consuming planning output is a separate operation. The standard carry-out engine's own insert/delete sequence participates in the source handoff rather than replacing it.
+
+See sample: [`carry-out-requisition-actions-through-the-standard-workflow.bad.al`](carry-out-requisition-actions-through-the-standard-workflow.bad.al).
+
+## References
+
+- [Perform planning action messages](https://learn.microsoft.com/en-us/dynamics365/business-central/production-how-to-run-mps-and-mrp#to-perform-action-messages)
+- [Planning functionality](https://learn.microsoft.com/en-us/dynamics365/business-central/production-about-planning-functionality)
+- [Reservation, order tracking, and action messaging](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-reservation-order-tracking-and-action-messaging)
+- [BaseApp carry-out caller and date defaults](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Requisition/CarryOutActionMsgReq.Report.al#L116-L133)
+- [Batch initialization and selection](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Requisition/ReqWkshMakeOrder.Codeunit.al#L116-L215)
+- [Reservation handoff before supply finalization](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Requisition/ReqWkshMakeOrder.Codeunit.al#L663-L743)
diff --git a/microsoft/knowledge/scm/change-item-applications-through-posting-routines.bad.al b/microsoft/knowledge/scm/change-item-applications-through-posting-routines.bad.al
new file mode 100644
index 0000000..f0239db
--- /dev/null
+++ b/microsoft/knowledge/scm/change-item-applications-through-posting-routines.bad.al
@@ -0,0 +1,33 @@
+codeunit 50104 "SCM Item Application Bad"
+{
+ procedure ChangeSalesQuantityApplication(ApplicationEntryNo: Integer; NewInboundEntryNo: Integer)
+ var
+ ItemApplicationEntry: Record "Item Application Entry";
+ OutboundItemLedgerEntry: Record "Item Ledger Entry";
+ InboundItemLedgerEntry: Record "Item Ledger Entry";
+ begin
+ ItemApplicationEntry.Get(ApplicationEntryNo);
+ ItemApplicationEntry.TestField(Quantity);
+ ItemApplicationEntry.TestField("Inbound Item Entry No.");
+ ItemApplicationEntry.TestField("Outbound Item Entry No.");
+ ItemApplicationEntry.TestField("Transferred-from Entry No.", 0);
+ if ItemApplicationEntry.CostApplication() then
+ Error(QuantityApplicationErr);
+ OutboundItemLedgerEntry.Get(ItemApplicationEntry."Outbound Item Entry No.");
+ OutboundItemLedgerEntry.TestField("Entry Type", OutboundItemLedgerEntry."Entry Type"::Sale);
+ OutboundItemLedgerEntry.TestField(Positive, false);
+ OutboundItemLedgerEntry.TestField("Drop Shipment", false);
+ OutboundItemLedgerEntry.TestField(Correction, false);
+ InboundItemLedgerEntry.Get(NewInboundEntryNo);
+ InboundItemLedgerEntry.TestField(Positive, true);
+ InboundItemLedgerEntry.TestField("Item No.", OutboundItemLedgerEntry."Item No.");
+ InboundItemLedgerEntry.TestField("Variant Code", OutboundItemLedgerEntry."Variant Code");
+ InboundItemLedgerEntry.TestField("Location Code", OutboundItemLedgerEntry."Location Code");
+
+ ItemApplicationEntry."Inbound Item Entry No." := NewInboundEntryNo;
+ ItemApplicationEntry.Modify(true);
+ end;
+
+ var
+ QuantityApplicationErr: Label 'Select an ordinary quantity application, not a cost application.';
+}
diff --git a/microsoft/knowledge/scm/change-item-applications-through-posting-routines.good.al b/microsoft/knowledge/scm/change-item-applications-through-posting-routines.good.al
new file mode 100644
index 0000000..e176aad
--- /dev/null
+++ b/microsoft/knowledge/scm/change-item-applications-through-posting-routines.good.al
@@ -0,0 +1,40 @@
+codeunit 50105 "SCM Item Application Good"
+{
+ procedure ChangeSalesQuantityApplication(ApplicationEntryNo: Integer; NewInboundEntryNo: Integer)
+ var
+ ItemApplicationEntry: Record "Item Application Entry";
+ OutboundItemLedgerEntry: Record "Item Ledger Entry";
+ InboundItemLedgerEntry: Record "Item Ledger Entry";
+ ItemJnlPostLine: Codeunit "Item Jnl.-Post Line";
+ OutboundEntryNo: Integer;
+ begin
+ ItemApplicationEntry.Get(ApplicationEntryNo);
+ ItemApplicationEntry.TestField(Quantity);
+ ItemApplicationEntry.TestField("Inbound Item Entry No.");
+ ItemApplicationEntry.TestField("Outbound Item Entry No.");
+ ItemApplicationEntry.TestField("Transferred-from Entry No.", 0);
+ if ItemApplicationEntry.CostApplication() then
+ Error(QuantityApplicationErr);
+ OutboundEntryNo := ItemApplicationEntry."Outbound Item Entry No.";
+ OutboundItemLedgerEntry.Get(OutboundEntryNo);
+ OutboundItemLedgerEntry.TestField("Entry Type", OutboundItemLedgerEntry."Entry Type"::Sale);
+ OutboundItemLedgerEntry.TestField(Positive, false);
+ OutboundItemLedgerEntry.TestField("Drop Shipment", false);
+ OutboundItemLedgerEntry.TestField(Correction, false);
+ InboundItemLedgerEntry.Get(NewInboundEntryNo);
+ InboundItemLedgerEntry.TestField(Positive, true);
+ InboundItemLedgerEntry.TestField("Item No.", OutboundItemLedgerEntry."Item No.");
+ InboundItemLedgerEntry.TestField("Variant Code", OutboundItemLedgerEntry."Variant Code");
+ InboundItemLedgerEntry.TestField("Location Code", OutboundItemLedgerEntry."Location Code");
+
+ ItemJnlPostLine.UnApply(ItemApplicationEntry);
+ OutboundItemLedgerEntry.Get(OutboundEntryNo);
+ ItemJnlPostLine.ReApply(OutboundItemLedgerEntry, NewInboundEntryNo);
+ ItemJnlPostLine.RedoApplications();
+ ItemJnlPostLine.CostAdjust();
+ ItemJnlPostLine.ClearApplicationLog();
+ end;
+
+ var
+ QuantityApplicationErr: Label 'Select an ordinary quantity application, not a cost application.';
+}
diff --git a/microsoft/knowledge/scm/change-item-applications-through-posting-routines.md b/microsoft/knowledge/scm/change-item-applications-through-posting-routines.md
new file mode 100644
index 0000000..5b5443e
--- /dev/null
+++ b/microsoft/knowledge/scm/change-item-applications-through-posting-routines.md
@@ -0,0 +1,39 @@
+---
+bc-version: [all]
+domain: scm
+keywords: [item-application-entry, inbound-item-entry-no, unapply, reapply, redoapplications, costadjust, application-worksheet]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Change item applications through posting routines
+
+## Description
+
+An `"Item Application Entry"` connects quantity application to cost flow; changing its inbound entry number is not merely fixing a foreign key. Unapplication/reapplication also affects ledger remaining quantities, open states, valuation, and entries needing cost adjustment. Direct edits can leave a plausible application row attached to inconsistent inventory and costs.
+
+## Best Practice
+
+The Application Worksheet provides the interactive correction workflow. A narrowly controlled programmatic correction of an ordinary quantity application uses the same `"Item Jnl.-Post Line"` instance for `UnApply`, a reload of the affected outbound item entry, and `ReApply` to the compatible inbound entry. Its finalization lifecycle includes `RedoApplications`, `CostAdjust`, and `ClearApplicationLog`.
+
+The posting routines enforce inventory-period, correction, transfer, and drop-shipment restrictions. Entries with `"Transferred-from Entry No."`, outbound transfers, and special application types are outside the ordinary-sales sample's scope. Application-check bypasses remove those protections, and the worksheet's multi-step recovery flags belong to its UI lifecycle rather than a standalone transaction.
+
+`CostAdjust` honors automatic-cost-adjustment setup; calling it does not mean all costs are settled when adjustment is disabled or deferred. Scheduled/manual adjustment remains necessary in those configurations. Temporary application projections, extension metadata, and source-document reservation/order-tracking changes are not edits to the persistent item-application graph.
+
+See sample: [`change-item-applications-through-posting-routines.good.al`](change-item-applications-through-posting-routines.good.al).
+
+## Anti Pattern
+
+Independent `Modify`, `Delete`, or replacement `Insert` operations on persistent `"Item Application Entry"` rows can repoint a receipt/shipment application without updating remaining quantities or cost propagation. Valid item numbers, matching quantities, and running table triggers do not complete reapplication.
+
+Omitting finalization or committing between unapply and reapply exposes an incomplete replacement. The standard posting/application workflow's internal table writes differ because they participate in that lifecycle.
+
+See sample: [`change-item-applications-through-posting-routines.bad.al`](change-item-applications-through-posting-routines.bad.al).
+
+## References
+
+- [Item application design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-item-application)
+- [Cost adjustment design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-cost-adjustment)
+- [BaseApp application finalization sequence](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Ledger/ApplicationWorksheet.Page.al#L495-L503)
+- [BaseApp reapplication and cost-adjustment lifecycle](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Posting/ItemJnlPostLine.Codeunit.al#L5464-L5538)
diff --git a/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.bad.al b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.bad.al
new file mode 100644
index 0000000..744ad53
--- /dev/null
+++ b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.bad.al
@@ -0,0 +1,26 @@
+codeunit 50181 "Scanner Receipt Import Bad"
+{
+ procedure PostScannedReceipt(ItemNo: Code[20]; LocationCode: Code[10]; UnitOfMeasureCode: Code[10]; ScannedQuantity: Decimal; DocumentNo: Code[20])
+ var
+ ItemJournalLine: Record "Item Journal Line";
+ ItemJnlPostLine: Codeunit "Item Jnl.-Post Line";
+ begin
+ if ScannedQuantity <= 0 then
+ Error(PositiveQuantityErr);
+
+ ItemJournalLine.Init();
+ ItemJournalLine.Validate("Posting Date", WorkDate());
+ ItemJournalLine.Validate("Entry Type", ItemJournalLine."Entry Type"::"Positive Adjmt.");
+ ItemJournalLine.Validate("Document No.", DocumentNo);
+ ItemJournalLine.Validate("Item No.", ItemNo);
+ ItemJournalLine.Validate("Location Code", LocationCode);
+ ItemJournalLine."Unit of Measure Code" := UnitOfMeasureCode;
+ ItemJournalLine.Quantity := ScannedQuantity;
+ ItemJournalLine."Quantity (Base)" := ScannedQuantity;
+
+ ItemJnlPostLine.RunWithCheck(ItemJournalLine);
+ end;
+
+ var
+ PositiveQuantityErr: Label 'The scanned quantity must be greater than zero.';
+}
diff --git a/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.good.al b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.good.al
new file mode 100644
index 0000000..6280b59
--- /dev/null
+++ b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.good.al
@@ -0,0 +1,25 @@
+codeunit 50180 "Scanner Receipt Import Good"
+{
+ procedure PostScannedReceipt(ItemNo: Code[20]; LocationCode: Code[10]; UnitOfMeasureCode: Code[10]; ScannedQuantity: Decimal; DocumentNo: Code[20])
+ var
+ ItemJournalLine: Record "Item Journal Line";
+ ItemJnlPostLine: Codeunit "Item Jnl.-Post Line";
+ begin
+ if ScannedQuantity <= 0 then
+ Error(PositiveQuantityErr);
+
+ ItemJournalLine.Init();
+ ItemJournalLine.Validate("Posting Date", WorkDate());
+ ItemJournalLine.Validate("Entry Type", ItemJournalLine."Entry Type"::"Positive Adjmt.");
+ ItemJournalLine.Validate("Document No.", DocumentNo);
+ ItemJournalLine.Validate("Item No.", ItemNo);
+ ItemJournalLine.Validate("Location Code", LocationCode);
+ ItemJournalLine.Validate("Unit of Measure Code", UnitOfMeasureCode);
+ ItemJournalLine.Validate(Quantity, ScannedQuantity);
+
+ ItemJnlPostLine.RunWithCheck(ItemJournalLine);
+ end;
+
+ var
+ PositiveQuantityErr: Label 'The scanned quantity must be greater than zero.';
+}
diff --git a/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.md b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.md
new file mode 100644
index 0000000..3f3d3a0
--- /dev/null
+++ b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.md
@@ -0,0 +1,37 @@
+---
+bc-version: [all]
+domain: scm
+keywords: [quantity-base, qty-per-unit-of-measure, unit-of-measure-code, unit-of-measure-management, calcbaseqty, getqtyperunitofmeasure, qty-rounding-precision, item-journal-line]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Derive base quantities through the line's unit of measure
+
+## Description
+
+Inventory, item ledger entries, reservations, item tracking, and warehouse quantities are measured in the item's base unit of measure. Document and journal lines hold `Quantity` in the line's `"Unit of Measure Code"`, together with `"Qty. per Unit of Measure"` and base-unit fields such as `"Quantity (Base)"`. Ten boxes of twelve pieces are 120 base units, not 10. When a line's `Quantity` is validated, the table derives the base quantity through its `CalcBaseQty` procedure. That procedure calls `"Unit of Measure Management".CalcBaseQty` with the line's quantity rounding precision and raises an error when rounding would turn a non-zero quantity into a zero base quantity. Code that bypasses this conversion creates a line whose quantity and base quantity disagree, or makes a stock decision in the wrong unit.
+
+## Best Practice
+
+On a document or journal line, validate `"Unit of Measure Code"` before `Quantity`, and validate both. Validating the unit of measure sets `"Qty. per Unit of Measure"` from the item unit of measure; validating the quantity then fills the base fields with the correct rounding. Compare line quantities with inventory or availability in base units, for example `"Quantity (Base)"` or `"Outstanding Qty. (Base)"`.
+
+Outside a line, get the factor with `"Unit of Measure Management".GetQtyPerUnitOfMeasure(Item, UnitOfMeasureCode)` and convert with its `CalcBaseQty` or `CalcQtyFromBase` procedures instead of multiplying by hand. Pass the item unit's quantity rounding precision where the available overload accepts it.
+
+Reading these fields for display, reporting, or a temporary buffer that is never posted is not a conversion defect. Code that proves the line uses the base unit of measure (`"Qty. per Unit of Measure"` equal to 1) is also correct, but don't assume this from the item alone, because a line can use another unit.
+
+See sample: [`derive-base-quantities-through-the-line-unit-of-measure.good.al`](derive-base-quantities-through-the-line-unit-of-measure.good.al).
+
+## Anti Pattern
+
+Assigning `Quantity` directly on an item journal, sales, purchase, or transfer line and then inserting, modifying, or posting it. Also assigning a base field such as `"Quantity (Base)" := Quantity`, multiplying by a hard-coded or separately looked-up factor without the line's rounding, or comparing a line's `Quantity` with `Item.Inventory` or another base-unit value. Detection signal: a direct `:=` to `Quantity`, `"Qty. per Unit of Measure"`, or a `(Base)` quantity field on a persisted or posted line, or a comparison between a non-base line quantity and an inventory quantity.
+
+See sample: [`derive-base-quantities-through-the-line-unit-of-measure.bad.al`](derive-base-quantities-through-the-line-unit-of-measure.bad.al).
+
+## References
+
+- [Set up units of measure, including quantity rounding precision](https://learn.microsoft.com/en-us/dynamics365/business-central/inventory-how-setup-units-of-measure)
+- [BCApps: Unit of Measure Management conversions](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/BaseApp/Foundation/UOM/UnitofMeasureManagement.Codeunit.al)
+- [BCApps: Item Journal Line quantity validation and CalcBaseQty](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/BaseApp/Inventory/Journal/ItemJournalLine.Table.al)
+- [BCApps: Sales Line quantity validation and CalcBaseQty](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/BaseApp/Sales/Document/SalesLine.Table.al)
diff --git a/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.bad.al b/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.bad.al
new file mode 100644
index 0000000..1187f96
--- /dev/null
+++ b/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.bad.al
@@ -0,0 +1,32 @@
+codeunit 50100 "SCM Stock Adjustment Bad"
+{
+ procedure PostPreparedPositiveAdjustment(ItemJournalLine: Record "Item Journal Line"; NewEntryNo: Integer)
+ var
+ ItemLedgerEntry: Record "Item Ledger Entry";
+ begin
+ ItemJournalLine.TestField("Entry Type", ItemJournalLine."Entry Type"::"Positive Adjmt.");
+ ItemJournalLine.TestField("Value Entry Type", ItemJournalLine."Value Entry Type"::"Direct Cost");
+ ItemJournalLine.TestField("Item No.");
+ ItemJournalLine.TestField("Posting Date");
+ ItemJournalLine.TestField("Quantity (Base)");
+ if ItemJournalLine."Quantity (Base)" < 0 then
+ Error(PositiveQuantityErr);
+
+ ItemLedgerEntry.Init();
+ ItemLedgerEntry."Entry No." := NewEntryNo;
+ ItemLedgerEntry."Item No." := ItemJournalLine."Item No.";
+ ItemLedgerEntry."Entry Type" := ItemJournalLine."Entry Type";
+ ItemLedgerEntry."Posting Date" := ItemJournalLine."Posting Date";
+ ItemLedgerEntry."Document No." := ItemJournalLine."Document No.";
+ ItemLedgerEntry."Location Code" := ItemJournalLine."Location Code";
+ ItemLedgerEntry."Variant Code" := ItemJournalLine."Variant Code";
+ ItemLedgerEntry.Quantity := ItemJournalLine."Quantity (Base)";
+ ItemLedgerEntry."Remaining Quantity" := ItemLedgerEntry.Quantity;
+ ItemLedgerEntry.Positive := true;
+ ItemLedgerEntry.Open := true;
+ ItemLedgerEntry.Insert(true);
+ end;
+
+ var
+ PositiveQuantityErr: Label 'The prepared adjustment must increase inventory.';
+}
diff --git a/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.good.al b/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.good.al
new file mode 100644
index 0000000..3e7866a
--- /dev/null
+++ b/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.good.al
@@ -0,0 +1,20 @@
+codeunit 50101 "SCM Stock Adjustment Good"
+{
+ procedure PostPreparedPositiveAdjustment(var ItemJournalLine: Record "Item Journal Line")
+ var
+ ItemJnlPostLine: Codeunit "Item Jnl.-Post Line";
+ begin
+ ItemJournalLine.TestField("Entry Type", ItemJournalLine."Entry Type"::"Positive Adjmt.");
+ ItemJournalLine.TestField("Value Entry Type", ItemJournalLine."Value Entry Type"::"Direct Cost");
+ ItemJournalLine.TestField("Item No.");
+ ItemJournalLine.TestField("Posting Date");
+ ItemJournalLine.TestField("Quantity (Base)");
+ if ItemJournalLine."Quantity (Base)" < 0 then
+ Error(PositiveQuantityErr);
+
+ ItemJnlPostLine.RunWithCheck(ItemJournalLine);
+ end;
+
+ var
+ PositiveQuantityErr: Label 'The prepared adjustment must increase inventory.';
+}
diff --git a/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.md b/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.md
new file mode 100644
index 0000000..0df6dac
--- /dev/null
+++ b/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.md
@@ -0,0 +1,42 @@
+---
+bc-version: [all]
+domain: scm
+keywords: [item-ledger-entry, value-entry, item-journal-line, item-jnl-post-line, runwithcheck, inventory-posting]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Post item ledger changes through item journals
+
+## Description
+
+An item ledger entry is not an independently insertable stock balance. Posting connects its quantity to item applications, reservations, tracking, and one or more value entries; expected cost, invoicing, revaluation, and later cost adjustment can produce different value entries for the same item entry. Running a table's insert trigger does not run this posting workflow or its configured inventory-to-G/L integration.
+
+## Best Practice
+
+The posting entry point for a prepared standalone item-journal movement is `"Item Jnl.-Post Line".RunWithCheck`. Persisted journal batches use `"Item Jnl.-Post Batch"`; sales, purchase, transfer, assembly, and production transactions retain their owning document/posting orchestration. Those workflows create the ledger, value, and application records together with their required checks.
+
+Supported posting events enrich validated journal/source data within the owning workflow. Read-only ledger queries, temporary ledger previews, extension-owned metadata fields, and publisher parameters consumed by the poster are not independent ledger posting. A temporary item-journal buffer can still produce persistent entries when passed to a posting codeunit. A publisher's `var` parameter or `IsHandled` flag alone does not supply the missing quantity/cost coordination; the normal engine's own inserts operate within that coordination.
+
+Not every value entry points to an item ledger entry: capacity and production WIP have their own supported posting relationships. Assembly and manufacturing posting retain order/component/routing and capacity context; one bare output/consumption call is not full order completion.
+
+The clean sample takes an already prepared positive-adjustment journal line. It is not a substitute for journal preparation, batch revaluation, warehouse reconciliation, or source-document posting.
+
+See sample: [`post-item-ledger-changes-through-item-journals.good.al`](post-item-ledger-changes-through-item-journals.good.al).
+
+## Anti Pattern
+
+Independent inserts/deletes of persistent `"Item Ledger Entry"` or `"Value Entry"` transaction rows, or overwrites of posted quantity, remaining quantity, application identity, or cost amounts, bypass the coordinated receipt, shipment, adjustment, or cost-correction workflow. `Insert(true)`, `Modify(true)`, and balanced-looking quantities do not supply that orchestration: stock can change without the corresponding application/value graph, or downstream cost flow can remain stale.
+
+A table declaration or custom annotation-field update does not change inventory quantities or costs and is outside this transaction-state concern.
+
+See sample: [`post-item-ledger-changes-through-item-journals.bad.al`](post-item-ledger-changes-through-item-journals.bad.al).
+
+## References
+
+- [Inventory posting design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-inventory-posting)
+- [Item application design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-item-application)
+- [BaseApp item-journal posting entry point](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Posting/ItemJnlPostLine.Codeunit.al#L162-L179)
+- [Assembly-order posting context](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-assembly-order-posting)
+- [Production-order posting context](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-production-order-posting)
diff --git a/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.bad.al b/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.bad.al
new file mode 100644
index 0000000..df450cc
--- /dev/null
+++ b/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.bad.al
@@ -0,0 +1,17 @@
+codeunit 50102 "SCM Revaluation Batch Bad"
+{
+ procedure PostCalculatedRevaluationBatch(TemplateName: Code[10]; BatchName: Code[10])
+ var
+ ItemJournalLine: Record "Item Journal Line";
+ ItemJnlPostLine: Codeunit "Item Jnl.-Post Line";
+ begin
+ ItemJournalLine.SetRange("Journal Template Name", TemplateName);
+ ItemJournalLine.SetRange("Journal Batch Name", BatchName);
+ ItemJournalLine.FindSet();
+ repeat
+ ItemJournalLine.TestField("Value Entry Type", ItemJournalLine."Value Entry Type"::Revaluation);
+ ItemJournalLine.TestField("Inventory Value Per");
+ ItemJnlPostLine.RunWithCheck(ItemJournalLine);
+ until ItemJournalLine.Next() = 0;
+ end;
+}
diff --git a/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.good.al b/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.good.al
new file mode 100644
index 0000000..810fad5
--- /dev/null
+++ b/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.good.al
@@ -0,0 +1,16 @@
+codeunit 50103 "SCM Revaluation Batch Good"
+{
+ procedure PostCalculatedRevaluationBatch(TemplateName: Code[10]; BatchName: Code[10])
+ var
+ ItemJournalLine: Record "Item Journal Line";
+ ItemJnlPostBatch: Codeunit "Item Jnl.-Post Batch";
+ begin
+ ItemJournalLine.SetRange("Journal Template Name", TemplateName);
+ ItemJournalLine.SetRange("Journal Batch Name", BatchName);
+ ItemJournalLine.FindFirst();
+ ItemJournalLine.TestField("Value Entry Type", ItemJournalLine."Value Entry Type"::Revaluation);
+ ItemJournalLine.TestField("Inventory Value Per");
+
+ ItemJnlPostBatch.Run(ItemJournalLine);
+ end;
+}
diff --git a/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.md b/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.md
new file mode 100644
index 0000000..9730c2d
--- /dev/null
+++ b/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.md
@@ -0,0 +1,38 @@
+---
+bc-version: [all]
+domain: scm
+keywords: [revaluation, inventory-value-per, partial-revaluation, item-jnl-post-batch, item-journal-line, runwithcheck, standard-cost]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Post calculated revaluation through the item journal batch
+
+## Description
+
+A calculated revaluation line with nonblank `"Inventory Value Per"` represents an aggregate, not a finalized posting against one item ledger entry. Codeunit `"Item Jnl.-Post Batch"` distributes that value over eligible entries, handles rounding, and coordinates Item/SKU standard-cost updates. Calling the line poster directly skips that batch work even though the input is a valid `"Item Journal Line"`.
+
+## Best Practice
+
+Posting a prepared revaluation batch through `"Item Jnl.-Post Batch"` preserves the calculated line's valuation date, aggregation scope, location/variant filters, and revaluation fields. The batch expands summarized values into per-entry postings and checks that the eligible inventory has not changed; for partial revaluation it also rechecks remaining quantity before posting.
+
+The public `"Item Jnl.-Post Line".RunWithCheck` API does not replace that orchestration. It remains legitimate for finalized individual-entry revaluation lines within a workflow that already supplies the necessary checks; the batch itself uses the line poster. Ordinary quantity journals and finalized per-entry revaluations are distinct from this summarized/partial-revaluation case.
+
+The samples explicitly require `"Value Entry Type" = Revaluation` and a nonblank `"Inventory Value Per"` in an existing calculated journal batch. They demonstrate posting, not how to calculate a new valuation or choose a standard cost.
+
+See sample: [`post-revaluation-through-the-item-journal-batch.good.al`](post-revaluation-through-the-item-journal-batch.good.al).
+
+## Anti Pattern
+
+A loop that sends calculated aggregate revaluation lines straight to `"Item Jnl.-Post Line"` skips distribution over the underlying item entries. A partial-revaluation workflow without the remaining-quantity recheck can post a valuation against inventory that no longer matches the calculation.
+
+Directly editing existing `"Value Entry"` cost amounts or the Item's unit cost does not repair those allocation and adjustment relationships. Their correction belongs to the revaluation/cost-adjustment workflow.
+
+See sample: [`post-revaluation-through-the-item-journal-batch.bad.al`](post-revaluation-through-the-item-journal-batch.bad.al).
+
+## References
+
+- [Revaluation design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-revaluation)
+- [Inventory posting design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-inventory-posting)
+- [BaseApp summarized revaluation and remaining-quantity checks](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Posting/ItemJnlPostBatch.Codeunit.al#L510-L714)
diff --git a/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.bad.al b/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.bad.al
new file mode 100644
index 0000000..2e14920
--- /dev/null
+++ b/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.bad.al
@@ -0,0 +1,22 @@
+codeunit 50112 "SCM Transfer Posting Bad"
+{
+ procedure ShipTransferOrder(TransferOrderNo: Code[20]; var ItemJournalLine: Record "Item Journal Line")
+ var
+ TransferHeader: Record "Transfer Header";
+ Location: Record Location;
+ ItemJnlPostLine: Codeunit "Item Jnl.-Post Line";
+ begin
+ TransferHeader.Get(TransferOrderNo);
+ TransferHeader.TestField("Direct Transfer", false);
+ TransferHeader.TestField("In-Transit Code");
+ Location.Get(TransferHeader."Transfer-from Code");
+ Location.TestField("Require Shipment", false);
+ ItemJournalLine.TestField("Entry Type", ItemJournalLine."Entry Type"::Transfer);
+ ItemJournalLine.TestField("Location Code", TransferHeader."Transfer-from Code");
+ ItemJournalLine.TestField("New Location Code", TransferHeader."In-Transit Code");
+
+ ItemJnlPostLine.RunWithCheck(ItemJournalLine);
+ TransferHeader."Last Shipment No." := ItemJournalLine."Document No.";
+ TransferHeader.Modify(true);
+ end;
+}
diff --git a/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.good.al b/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.good.al
new file mode 100644
index 0000000..8507c99
--- /dev/null
+++ b/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.good.al
@@ -0,0 +1,32 @@
+codeunit 50113 "SCM Transfer Posting Good"
+{
+ procedure ShipTransferOrder(TransferOrderNo: Code[20])
+ var
+ TransferHeader: Record "Transfer Header";
+ Location: Record Location;
+ TransferOrderPostShipment: Codeunit "TransferOrder-Post Shipment";
+ begin
+ TransferHeader.Get(TransferOrderNo);
+ TransferHeader.TestField("Direct Transfer", false);
+ TransferHeader.TestField("In-Transit Code");
+ Location.Get(TransferHeader."Transfer-from Code");
+ Location.TestField("Require Shipment", false);
+
+ TransferOrderPostShipment.Run(TransferHeader);
+ end;
+
+ procedure ReceiveTransferOrder(TransferOrderNo: Code[20])
+ var
+ TransferHeader: Record "Transfer Header";
+ Location: Record Location;
+ TransferOrderPostReceipt: Codeunit "TransferOrder-Post Receipt";
+ begin
+ TransferHeader.Get(TransferOrderNo);
+ TransferHeader.TestField("Direct Transfer", false);
+ TransferHeader.TestField("In-Transit Code");
+ Location.Get(TransferHeader."Transfer-to Code");
+ Location.TestField("Require Receive", false);
+
+ TransferOrderPostReceipt.Run(TransferHeader);
+ end;
+}
diff --git a/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.md b/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.md
new file mode 100644
index 0000000..b541dba
--- /dev/null
+++ b/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.md
@@ -0,0 +1,41 @@
+---
+bc-version: [all]
+domain: scm
+keywords: [transfer-header, transfer-line, transferorder-post-shipment, transferorder-post-receipt, in-transit-code, last-shipment-no, item-application-entry]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Post transfers through shipment and receipt codeunits
+
+## Description
+
+A two-step transfer order preserves a continuous quantity, reservation, and cost/application lineage from the source through in-transit to the destination. Its shipment and receipt workflows also own posted documents and derived lines for partial receipt. Posting an item-journal movement and manually marking a transfer header or line as shipped is not equivalent, even if the total quantities balance.
+
+## Best Practice
+
+A prepared non-direct transfer order without required warehouse documents uses `"TransferOrder-Post Shipment".Run` at shipment and `"TransferOrder-Post Receipt".Run` at receipt, with the actual `"Transfer Header"`. Validated source quantities to ship/receive prepare the operation; posted quantity counters are results of posting.
+
+Required warehouse shipment or receipt enters the warehouse document posting workflow, which invokes the transfer poster with its real source context. Direct transfers have their configured standard workflow; the two-step sample's in-transit guard is not a universal requirement.
+
+Standalone item reclassification journals and bin movements are legitimate separate operations, not completion of an existing transfer order. Tracking/application splits and average-cost handling mean transfers do not have one fixed item-entry count or a nonzero `"Transferred-from Entry No."` on every application.
+
+See sample: [`post-transfers-through-shipment-and-receipt-codeunits.good.al`](post-transfers-through-shipment-and-receipt-codeunits.good.al).
+
+## Anti Pattern
+
+Ad-hoc item postings, independent positive/negative adjustments, manually created posted-transfer rows, and direct shipment/receipt-counter changes cannot substitute for transfer-order posting. Updating `"Last Shipment No."` after a bare item-journal call does not create the posted shipment, source-line progress, or transfer application lineage.
+
+Changing an existing item ledger entry's location or inventing application links does not repair that missing workflow. Metadata enrichment within the normal shipment/receipt or direct-transfer workflow is distinct from replacing the posting operation.
+
+See sample: [`post-transfers-through-shipment-and-receipt-codeunits.bad.al`](post-transfers-through-shipment-and-receipt-codeunits.bad.al).
+
+## References
+
+- [Transfer inventory between locations](https://learn.microsoft.com/en-us/dynamics365/business-central/inventory-how-transfer-between-locations)
+- [Item application design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-item-application)
+- [Cost adjustment design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-cost-adjustment)
+- [BaseApp shipment journal/source linkage](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Transfer/TransferOrderPostShipment.Codeunit.al#L292-L336)
+- [Partial-receipt derived-line handling](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Transfer/TransferOrderPostReceipt.Codeunit.al#L457-L529)
+- [Transfer application and average-cost branches](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Posting/ItemJnlPostLine.Codeunit.al#L1911-L1976)
diff --git a/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.bad.al b/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.bad.al
new file mode 100644
index 0000000..97c7cb4
--- /dev/null
+++ b/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.bad.al
@@ -0,0 +1,37 @@
+codeunit 50110 "SCM Warehouse Adjustment Bad"
+{
+ procedure ReconcileRegisteredWarehouseAdjustment(ItemNo: Code[20]; LocationCode: Code[10]; TemplateName: Code[10]; BatchName: Code[10]; PostingDate: Date; DocumentNo: Code[20]): Boolean
+ var
+ Item: Record Item;
+ ItemJournalBatch: Record "Item Journal Batch";
+ ItemJournalLine: Record "Item Journal Line";
+ Location: Record Location;
+ CalculateWhseAdjustment: Report "Calculate Whse. Adjustment";
+ begin
+ Location.Get(LocationCode);
+ Location.TestField("Directed Put-away and Pick", true);
+ Location.TestField("Adjustment Bin Code");
+ ItemJournalBatch.Get(TemplateName, BatchName);
+ ItemJournalLine.SetRange("Journal Template Name", TemplateName);
+ ItemJournalLine.SetRange("Journal Batch Name", BatchName);
+ if not ItemJournalLine.IsEmpty() then
+ Error(EmptyBatchErr);
+
+ Item.Get(ItemNo);
+ Item.SetRecFilter();
+ Item.SetRange("Location Filter", LocationCode);
+ ItemJournalLine."Journal Template Name" := TemplateName;
+ ItemJournalLine."Journal Batch Name" := BatchName;
+ CalculateWhseAdjustment.SetItemJnlLine(ItemJournalLine);
+ CalculateWhseAdjustment.SetTableView(Item);
+ CalculateWhseAdjustment.InitializeRequest(PostingDate, DocumentNo);
+ CalculateWhseAdjustment.SetHideValidationDialog(true);
+ CalculateWhseAdjustment.UseRequestPage(false);
+ CalculateWhseAdjustment.RunModal();
+
+ exit(true);
+ end;
+
+ var
+ EmptyBatchErr: Label 'Use an empty, dedicated item journal batch for warehouse reconciliation.';
+}
diff --git a/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.good.al b/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.good.al
new file mode 100644
index 0000000..469ed2d
--- /dev/null
+++ b/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.good.al
@@ -0,0 +1,42 @@
+codeunit 50111 "SCM Warehouse Adjustment Good"
+{
+ procedure ReconcileRegisteredWarehouseAdjustment(ItemNo: Code[20]; LocationCode: Code[10]; TemplateName: Code[10]; BatchName: Code[10]; PostingDate: Date; DocumentNo: Code[20]): Boolean
+ var
+ Item: Record Item;
+ ItemJournalBatch: Record "Item Journal Batch";
+ ItemJournalLine: Record "Item Journal Line";
+ Location: Record Location;
+ CalculateWhseAdjustment: Report "Calculate Whse. Adjustment";
+ ItemJnlPostBatch: Codeunit "Item Jnl.-Post Batch";
+ begin
+ Location.Get(LocationCode);
+ Location.TestField("Directed Put-away and Pick", true);
+ Location.TestField("Adjustment Bin Code");
+ ItemJournalBatch.Get(TemplateName, BatchName);
+ ItemJournalLine.SetRange("Journal Template Name", TemplateName);
+ ItemJournalLine.SetRange("Journal Batch Name", BatchName);
+ if not ItemJournalLine.IsEmpty() then
+ Error(EmptyBatchErr);
+
+ Item.Get(ItemNo);
+ Item.SetRecFilter();
+ Item.SetRange("Location Filter", LocationCode);
+ ItemJournalLine."Journal Template Name" := TemplateName;
+ ItemJournalLine."Journal Batch Name" := BatchName;
+ CalculateWhseAdjustment.SetItemJnlLine(ItemJournalLine);
+ CalculateWhseAdjustment.SetTableView(Item);
+ CalculateWhseAdjustment.InitializeRequest(PostingDate, DocumentNo);
+ CalculateWhseAdjustment.SetHideValidationDialog(true);
+ CalculateWhseAdjustment.UseRequestPage(false);
+ CalculateWhseAdjustment.RunModal();
+
+ if ItemJournalLine.FindFirst() then begin
+ ItemJournalLine.TestField("Warehouse Adjustment", true);
+ ItemJnlPostBatch.Run(ItemJournalLine);
+ end;
+ exit(true);
+ end;
+
+ var
+ EmptyBatchErr: Label 'Use an empty, dedicated item journal batch for warehouse reconciliation.';
+}
diff --git a/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.md b/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.md
new file mode 100644
index 0000000..06230e7
--- /dev/null
+++ b/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.md
@@ -0,0 +1,40 @@
+---
+bc-version: [all]
+domain: scm
+keywords: [warehouse-adjustment, calculate-whse-adjustment, adjustment-bin-code, directed-put-away-and-pick, item-journal-line, warehouse-entry]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Reconcile warehouse adjustments with the item ledger
+
+## Description
+
+At a Directed Put-away and Pick location, registering an ordinary warehouse quantity or physical-inventory adjustment and synchronizing it to inventory are distinct steps. The warehouse registration balances quantity through the adjustment bin. An additional ordinary item-journal increase/decrease is not the same as consuming that pending warehouse adjustment.
+
+## Best Practice
+
+After warehouse registration, `"Calculate Whse. Adjustment"` prepares item-journal lines for the intended item/location and batch; `"Item Jnl.-Post Batch"` posts those lines. The calculation derives the reconciliation by location, variant, units of measure, and tracking, marks the lines `"Warehouse Adjustment"`, and accounts for already prepared unposted adjustments.
+
+Reconciliation is separate from source-document posting: warehouse receipts/shipments use their document workflows. Intentional warehouse-only staging is valid when a separately owned reconciliation step completes the process; registration need not perform both phases in one call.
+
+Bin movements need not change total inventory, and warehouse tracking/expiration reclassification has a standard batch path that can also post item-journal entries. Standard reclassification and basic-location item adjustments are not this ordinary advanced-warehouse quantity-adjustment case.
+
+The samples start after warehouse quantity registration and report whether inventory reconciliation completed. The clean sample calculates and posts into an empty dedicated batch; it is not a complete warehouse physical-count workflow.
+
+See sample: [`reconcile-warehouse-adjustments-with-the-item-ledger.good.al`](reconcile-warehouse-adjustments-with-the-item-ledger.good.al).
+
+## Anti Pattern
+
+A manually mirrored ordinary item-journal line does not reconcile a registered advanced-warehouse quantity adjustment. Likewise, a reconciliation function that returns completion after only registration or adjustment calculation leaves any generated adjustment lines unposted. Calculation prepares journal lines; it does not post them.
+
+Invented positive/negative quantities or flipping `"Warehouse Adjustment"` on an arbitrary line does not establish the required relationship to the adjustment-bin balance and tracked quantities. That relationship comes from the calculation step.
+
+See sample: [`reconcile-warehouse-adjustments-with-the-item-ledger.bad.al`](reconcile-warehouse-adjustments-with-the-item-ledger.bad.al).
+
+## References
+
+- [Synchronize adjusted warehouse entries with item ledger entries](https://learn.microsoft.com/en-us/dynamics365/business-central/inventory-how-count-adjust-reclassify#to-synchronize-the-adjusted-warehouse-entries-with-the-related-item-ledger-entries)
+- [BaseApp warehouse-adjustment calculation](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Warehouse/Journal/CalculateWhseAdjustment.Report.al#L298-L384)
+- [Warehouse reclassification exception](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Warehouse/Journal/WhseJnlRegisterBatch.Codeunit.al#L196-L210)
diff --git a/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.bad.al b/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.bad.al
new file mode 100644
index 0000000..89368cf
--- /dev/null
+++ b/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.bad.al
@@ -0,0 +1,30 @@
+codeunit 50108 "SCM Tracking Transfer Bad"
+{
+ procedure TransferBlanketOrderTracking(var SourceBlanketOrderLine: Record "Sales Line"; var DestinationSalesOrderLine: Record "Sales Line"; QuantityBaseToTransfer: Decimal)
+ var
+ ReservationEntry: Record "Reservation Entry";
+ begin
+ SourceBlanketOrderLine.TestField("Document Type", SourceBlanketOrderLine."Document Type"::"Blanket Order");
+ SourceBlanketOrderLine.TestField(Type, SourceBlanketOrderLine.Type::Item);
+ DestinationSalesOrderLine.TestField("Document Type", DestinationSalesOrderLine."Document Type"::Order);
+ DestinationSalesOrderLine.TestField(Type, DestinationSalesOrderLine.Type::Item);
+ DestinationSalesOrderLine.TestField("No.", SourceBlanketOrderLine."No.");
+ if QuantityBaseToTransfer <= 0 then
+ Error(PositiveQuantityErr);
+
+ ReservationEntry.SetRange("Source Type", Database::"Sales Line");
+ ReservationEntry.SetRange("Source Subtype", SourceBlanketOrderLine."Document Type".AsInteger());
+ ReservationEntry.SetRange("Source ID", SourceBlanketOrderLine."Document No.");
+ ReservationEntry.SetRange("Source Ref. No.", SourceBlanketOrderLine."Line No.");
+ ReservationEntry.SetRange(Positive, false);
+ ReservationEntry.FindFirst();
+ ReservationEntry."Source Subtype" := DestinationSalesOrderLine."Document Type".AsInteger();
+ ReservationEntry."Source ID" := DestinationSalesOrderLine."Document No.";
+ ReservationEntry."Source Ref. No." := DestinationSalesOrderLine."Line No.";
+ ReservationEntry.Validate("Quantity (Base)", -QuantityBaseToTransfer);
+ ReservationEntry.Modify(true);
+ end;
+
+ var
+ PositiveQuantityErr: Label 'The base quantity to transfer must be positive.';
+}
diff --git a/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.good.al b/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.good.al
new file mode 100644
index 0000000..a655b34
--- /dev/null
+++ b/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.good.al
@@ -0,0 +1,20 @@
+codeunit 50109 "SCM Tracking Transfer Good"
+{
+ procedure TransferBlanketOrderTracking(var SourceBlanketOrderLine: Record "Sales Line"; var DestinationSalesOrderLine: Record "Sales Line"; QuantityBaseToTransfer: Decimal)
+ var
+ SalesLineReserve: Codeunit "Sales Line-Reserve";
+ begin
+ SourceBlanketOrderLine.TestField("Document Type", SourceBlanketOrderLine."Document Type"::"Blanket Order");
+ SourceBlanketOrderLine.TestField(Type, SourceBlanketOrderLine.Type::Item);
+ DestinationSalesOrderLine.TestField("Document Type", DestinationSalesOrderLine."Document Type"::Order);
+ DestinationSalesOrderLine.TestField(Type, DestinationSalesOrderLine.Type::Item);
+ DestinationSalesOrderLine.TestField("No.", SourceBlanketOrderLine."No.");
+ if QuantityBaseToTransfer <= 0 then
+ Error(PositiveQuantityErr);
+
+ SalesLineReserve.TransferSaleLineToSalesLine(SourceBlanketOrderLine, DestinationSalesOrderLine, QuantityBaseToTransfer);
+ end;
+
+ var
+ PositiveQuantityErr: Label 'The base quantity to transfer must be positive.';
+}
diff --git a/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.md b/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.md
new file mode 100644
index 0000000..8bc42cc
--- /dev/null
+++ b/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.md
@@ -0,0 +1,41 @@
+---
+bc-version: [all]
+domain: scm
+keywords: [reservation-entry, tracking-specification, sales-line-reserve, transfersalelinetosalesline, transferreserventry, copyitemtracking, quantity-base]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Transfer item tracking through source reservation codeunits
+
+## Description
+
+Moving lot/serial tracking between document lines is a source-ownership operation, not a copy of visible tracking fields. Partial movement must retain the old source's remainder, destination units of measure, quantities to handle/invoice, status, sign, and any reservation counterpart. Repointing a `"Reservation Entry"` loses this coordination; inserting a `"Tracking Specification"` row alone does not book the destination's source tracking.
+
+## Best Practice
+
+Reservation codeunits provide source-specific tracking workflows. For the tracking portion of blanket-sales-order or quote conversion to a sales order, `"Sales Line-Reserve".TransferSaleLineToSalesLine` takes the existing source line, prepared destination line, and quantity to transfer in **base units**. It delegates the source/status and quantity movement to `"Create Reserv. Entry".TransferReservEntry`.
+
+The caller still owns document conversion and destination-line preparation; this method does not create a sales order. The source and destination must have compatible item, variant, location, and source identity. Purchases, transfers, assembly, and production have their own source-specific wrappers rather than sharing the sales conversion contract.
+
+`"Item Tracking Management".CopyItemTracking` serves a different purpose: it creates Prospect copies, not a transfer of reservation ownership. That is valid for its intended copy workflow. Temporary Tracking Specification processing and persisted historical tracking specifications are also normal; the working/historic representation differs from the current source booking.
+
+See sample: [`transfer-item-tracking-through-source-reservation-codeunits.good.al`](transfer-item-tracking-through-source-reservation-codeunits.good.al).
+
+## Anti Pattern
+
+Direct rewrites of persistent `"Reservation Entry"` source type/subtype, ID, reference number, or quantities do not perform the source-line conversion or partial tracking-transfer workflow. Changing only `"Quantity (Base)"` and source keys can drop the remainder or leave the other tracking/reservation quantities attached to the wrong source.
+
+A tracking copy cannot replace movement of an existing binding reservation. Legitimate Prospect copying, temporary tracking buffers, historical tracking reads, and source-specific engine calls serve distinct purposes and are not independent reservation transfers.
+
+See sample: [`transfer-item-tracking-through-source-reservation-codeunits.bad.al`](transfer-item-tracking-through-source-reservation-codeunits.bad.al).
+
+## References
+
+- [Item Tracking Lines window design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-item-tracking-lines-window)
+- [Active versus historic item-tracking entries](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-active-versus-historic-item-tracking-entries)
+- [Item tracking and reservations](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-item-tracking-and-reservations)
+- [BaseApp sales tracking transfer](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Sales/Document/SalesLineReserve.Codeunit.al#L532-L578)
+- [Base-unit conversion caller](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Sales/Document/BlanketSalesOrdertoOrder.Codeunit.al#L195-L198)
+- [Prospect-copy API](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Tracking/ItemTrackingManagement.Codeunit.al#L575-L657)
diff --git a/microsoft/knowledge/scm/use-date-aware-availability-for-promising.bad.al b/microsoft/knowledge/scm/use-date-aware-availability-for-promising.bad.al
new file mode 100644
index 0000000..b90f932
--- /dev/null
+++ b/microsoft/knowledge/scm/use-date-aware-availability-for-promising.bad.al
@@ -0,0 +1,21 @@
+codeunit 50114 "SCM Additional Promise Bad"
+{
+ procedure CanPromiseAdditionalDemand(ItemNo: Code[20]; LocationCode: Code[10]; VariantCode: Code[10]; ShipmentDate: Date; RequestedAdditionalQuantityBase: Decimal; LookaheadDateFormula: DateFormula): Boolean
+ var
+ Item: Record Item;
+ begin
+ if (ShipmentDate = 0D) or (RequestedAdditionalQuantityBase <= 0) then
+ Error(DemandInputErr);
+ Item.Get(ItemNo);
+ Item.TestField(Type, Item.Type::Inventory);
+ Item.SetRange("Location Filter", LocationCode);
+ Item.SetRange("Variant Filter", VariantCode);
+ Item.SetRange("Date Filter", 0D, ShipmentDate);
+
+ Item.CalcFields(Inventory);
+ exit(Item.Inventory >= RequestedAdditionalQuantityBase);
+ end;
+
+ var
+ DemandInputErr: Label 'Enter a shipment date and a positive additional base quantity.';
+}
diff --git a/microsoft/knowledge/scm/use-date-aware-availability-for-promising.good.al b/microsoft/knowledge/scm/use-date-aware-availability-for-promising.good.al
new file mode 100644
index 0000000..b33ee79
--- /dev/null
+++ b/microsoft/knowledge/scm/use-date-aware-availability-for-promising.good.al
@@ -0,0 +1,27 @@
+codeunit 50115 "SCM Additional Promise Good"
+{
+ procedure CanPromiseAdditionalDemand(ItemNo: Code[20]; LocationCode: Code[10]; VariantCode: Code[10]; ShipmentDate: Date; RequestedAdditionalQuantityBase: Decimal; LookaheadDateFormula: DateFormula): Boolean
+ var
+ Item: Record Item;
+ AvailableToPromise: Codeunit "Available to Promise";
+ GrossRequirement: Decimal;
+ ScheduledReceipt: Decimal;
+ PromisableQuantityBase: Decimal;
+ begin
+ if (ShipmentDate = 0D) or (RequestedAdditionalQuantityBase <= 0) then
+ Error(DemandInputErr);
+ Item.Get(ItemNo);
+ Item.TestField(Type, Item.Type::Inventory);
+ Item.SetRange("Location Filter", LocationCode);
+ Item.SetRange("Variant Filter", VariantCode);
+ Item.SetRange("Date Filter", 0D, ShipmentDate);
+
+ PromisableQuantityBase := AvailableToPromise.CalcQtyAvailableToPromise(
+ Item, GrossRequirement, ScheduledReceipt, ShipmentDate,
+ Enum::"Analysis Period Type"::Day, LookaheadDateFormula);
+ exit(PromisableQuantityBase >= RequestedAdditionalQuantityBase);
+ end;
+
+ var
+ DemandInputErr: Label 'Enter a shipment date and a positive additional base quantity.';
+}
diff --git a/microsoft/knowledge/scm/use-date-aware-availability-for-promising.md b/microsoft/knowledge/scm/use-date-aware-availability-for-promising.md
new file mode 100644
index 0000000..a182a94
--- /dev/null
+++ b/microsoft/knowledge/scm/use-date-aware-availability-for-promising.md
@@ -0,0 +1,39 @@
+---
+bc-version: [all]
+domain: scm
+keywords: [available-to-promise, calcqtyavailabletopromise, inventory, shipment-date, gross-requirement, scheduled-receipt, location-filter, variant-filter]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Use date-aware availability for promising
+
+## Description
+
+`Item.Inventory` is an on-hand quantity, not an available-to-promise answer. Promising additional demand must account for the requested date, location and variant, reservations, scheduled receipts, existing requirements, and demand within the configured lookahead. An on-hand comparison can promise inventory already committed elsewhere and miss incoming supply.
+
+## Best Practice
+
+For additional demand not already recorded on a source line, `"Available to Promise".CalcQtyAvailableToPromise` uses the Item's location/variant filters, date range ending on the shipment date, and configured period/lookahead horizon. Its result and the additional quantity are compared in base units. A fresh calculation context or the codeunit's recalculation support avoids carrying cached quantities between unrelated items or requests.
+
+The source-aware order-promising/availability workflow accounts for an existing sales line's own quantity or delta; applying an additional-demand calculation to that line can double-count it. Assembly and production requirements/supply likewise participate in the standard availability context, not just a sales-only stock subtraction.
+
+An ATP result is not a reservation or a guarantee of warehouse pickability. Lot/serial constraints, bins, warehouse activity, and later concurrent changes still need their own checks. Conversely, an on-hand display, valuation report, or deliberately immediate-stock-only check can use `Item.Inventory`: it answers a different business question from ATP.
+
+See sample: [`use-date-aware-availability-for-promising.good.al`](use-date-aware-availability-for-promising.good.al).
+
+## Anti Pattern
+
+`CalcFields(Inventory)` or an equivalent item-ledger quantity sum used as the complete decision for a dated additional-demand promise ignores existing demand and incoming supply, even with location/variant filters. An Inventory FlowField read for an on-hand display has no such promising contract.
+
+Losing location, variant, date, or source-line context changes the calculation's business meaning. Another supported workflow that preserves the same availability semantics does not have to call this exact API.
+
+See sample: [`use-date-aware-availability-for-promising.bad.al`](use-date-aware-availability-for-promising.bad.al).
+
+## References
+
+- [Calculate order-promising dates](https://learn.microsoft.com/en-us/dynamics365/business-central/sales-how-to-calculate-order-promising-dates)
+- [Availability in the warehouse](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-availability-in-the-warehouse)
+- [BaseApp ATP calculation](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Availability/AvailabletoPromise.Codeunit.al#L52-L184)
+- [Forward-demand lookahead](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Availability/AvailabletoPromise.Codeunit.al#L295-L356)
diff --git a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md
index 7441712..649c384 100644
--- a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md
+++ b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md
@@ -15,8 +15,8 @@ AL does not ship a built-in `HtmlEncode` (or equivalent) function. Code that bui
## Best Practice
-Replace the four characters by hand before concatenating user content into HTML: `&` β `&` first, then `<` β `<`, `>` β `>`, `"` β `"`. Centralize the substitution in one helper so every HTML producer in the extension uses the same encoder. Better still, do not build raw HTML at all β use a structured format (JSON for an API payload, a report layout for a printed document) and let the renderer do the encoding. See sample: `al-has-no-built-in-htmlencode.good.al`.
+Replace the four characters by hand before concatenating user content into HTML: `&` β `&` first, then `<` β `<`, `>` β `>`, `"` β `"`. Centralize the substitution in one helper so every HTML producer in the extension uses the same encoder. Better still, do not build raw HTML at all β use a structured format (JSON for an API payload, a report layout for a printed document) and let the renderer do the encoding. See sample: [`al-has-no-built-in-htmlencode.good.al`](al-has-no-built-in-htmlencode.good.al).
## Anti Pattern
-`HtmlContent := 'Welcome ' + UserName + '!
'` β any record-field value or user input concatenated directly into an HTML string. Reviewers should flag any string concatenation whose right-hand operand is a field, a parameter, or any non-literal value, and whose surrounding context contains HTML tags (`<`, ``, `
'` β any record-field value or user input concatenated directly into an HTML string. Reviewers should flag any string concatenation whose right-hand operand is a field, a parameter, or any non-literal value, and whose surrounding context contains HTML tags (`<`, ``, `
`, `=`, `*`, `?`, `@`, parentheses, and single quotes are operators. Text from a user, request page, API payload, file, or another system that is concatenated into that expression can therefore change which records match: `*` matches every value, `A|B` widens an exact lookup to two values, `10000..` becomes a range, and `J & V` becomes a conjunction or an invalid filter. `SetFilter` with an empty expression applies no filter at all. When the filtered record is then modified, deleted, exported, or used for a permission-relevant decision, the procedure acts on records the caller never identified.
+
+## Best Practice
+
+Treat an externally supplied identifier as a value. Use `SetRange(Field, Value)` for equality and `SetRange(Field, FromValue, ToValue)` for a typed range; neither parses operators. Reject an empty identifier explicitly when "no value" must not mean "every record". For several external values, apply `SetRange` once per value instead of joining them with `|`.
+
+Use `SetFilter` when an operator is part of the procedure's own contract. Keep the operator in a constant expression and pass operands through replacement fields (`%1`, `%2`) of the field's data type, such as a `Date` or `Decimal` operand for `'>=%1'`. Do not rely on wrapping text in single quotes to neutralize it: according to the filter syntax, quotes protect `&`, `(`, `)`, `=`, and `|`, but `*` still acts as a wildcard inside `'J & V*'`.
+
+Text that the user deliberately entered as a filter is supposed to be parsed. Do not report a request-page or `FilterPageBuilder` filter, a `GetFilters`/`GetView` round trip, a FlowFilter, or a field whose documented purpose is to hold a filter expression. Constant filter strings and operands produced by the extension's own code are also not external input.
+
+See sample: [`do-not-concatenate-external-text-into-setfilter.good.al`](do-not-concatenate-external-text-into-setfilter.good.al).
+
+## Anti Pattern
+
+`Rec.SetFilter(Field, ExternalText)` or `Rec.SetFilter(Field, Prefix + ExternalText + Suffix)` where the text is meant to identify one record or a known list of records, with no validation that restricts it to a literal value. The finding is strongest when the resulting set is written by `Modify`, `ModifyAll`, `Delete`, or `DeleteAll`, or is returned to an external caller. Detection signal: a non-constant expression, concatenation, or `StrSubstNo` result passed as the `String` argument of `SetFilter`, where the operand comes from a parameter, page field, JSON/XML value, file line, or HTTP request.
+
+See sample: [`do-not-concatenate-external-text-into-setfilter.bad.al`](do-not-concatenate-external-text-into-setfilter.bad.al).
+
+## References
+
+- [Record.SetFilter method, including the empty-filter remark](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-setfilter-method)
+- [Filtering with SetRange and SetFilter](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-setcurrentkey-setrange-setfilter-getrangemin-and-getrangemax-methods)
+- [Filter criteria, operators, and values that contain symbols](https://learn.microsoft.com/en-us/dynamics365/business-central/ui-enter-criteria-filters#filter-criteria-and-operators)
diff --git a/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.bad.al b/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.bad.al
new file mode 100644
index 0000000..1a93861
--- /dev/null
+++ b/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.bad.al
@@ -0,0 +1,12 @@
+permissionset 50100 "Sample - Integration"
+{
+ Access = Public;
+ Assignable = false;
+ Caption = 'Sample Integration';
+ Permissions =
+ tabledata "Sample Order" = RIMD;
+ // BUG: "Sample Order API" (PageType = API) and "Sample Order Query"
+ // (a published API query) have no "= X" entry anywhere in this app.
+ // Both endpoints are unreachable even though the table looks fully
+ // granted - nobody decided who may call them.
+}
diff --git a/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.good.al b/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.good.al
new file mode 100644
index 0000000..de8d2ab
--- /dev/null
+++ b/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.good.al
@@ -0,0 +1,10 @@
+permissionset 50100 "Sample - Integration"
+{
+ Access = Public;
+ Assignable = false;
+ Caption = 'Sample Integration';
+ Permissions =
+ tabledata "Sample Order" = RIMD,
+ page "Sample Order API" = X, // exposed API page: execute granted
+ query "Sample Order Query" = X; // exposed API query: execute granted
+}
diff --git a/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.md b/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.md
new file mode 100644
index 0000000..fd933d7
--- /dev/null
+++ b/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.md
@@ -0,0 +1,32 @@
+---
+bc-version: [all]
+domain: security
+keywords: [permission-set, api-page, web-service, exposure, access-control]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Every exposed object must belong to a permission set
+
+## Description
+
+An object that is reachable from outside the app's own UI is only usable if it is also granted execute access through a permission set. Three distinct mechanisms make an object reachable this way, each with its own permission target:
+
+- A page or query published through the **Web Services** configuration page, or a custom REST endpoint declared with `PageType = API` / `QueryType = API` β both need a `page "..." = X` / `query "..." = X` entry for that object.
+- A codeunit published through **Web Services** exposes *every* public procedure on it as a SOAP operation automatically β there is no per-method attribute to add. SOAP web service support is deprecated and scheduled for removal; prefer publishing an API page/query for a new integration rather than a new codeunit web service. The permission target for an existing published codeunit is the codeunit itself: `codeunit "..." = X`.
+- `[ServiceEnabled]` is a method-level attribute used on a *page* procedure to expose it as an OData v4 bound action (for example a `Post` action on an invoice page) β it does not apply to pages, queries, or codeunits as an object-level property, and it does not create its own permission target. The action is still a call into that page object, so the page's own `page "..." = X` entry is what governs it.
+
+When such an object is left out of every permission set, it becomes both unusable (no caller, human or service, can reach it) and invisible in review: nobody deliberately decided who may call it. Exposure without a matching grant is not a safe default; it is an endpoint nobody is governing.
+
+## Best Practice
+
+Give every exposed object an explicit execute entry in a permission set shipped by the app: `page "..." = X` / `query "..." = X` for a published or API page/query (including one that exposes a `[ServiceEnabled]` bound action), and `codeunit "..." = X` for a codeunit published as a web service. Route sensitive endpoints into a dedicated, non-default admin permission set so reaching them requires a deliberate grant rather than being included by default. If an object should never be reachable from outside the app, remove the exposure itself (drop `PageType = API` / the Web Services registration) rather than leaving an orphaned endpoint with no permission-set membership.
+
+See sample: [`exposed-objects-must-be-in-a-permission-set.good.al`](exposed-objects-must-be-in-a-permission-set.good.al).
+
+## Anti Pattern
+
+Granting access to the underlying table data while forgetting to grant execute access to the exposed page or query itself. The table looks fully covered by a permission set, but the API/service layer in front of it has no `= X` entry anywhere, so the endpoint silently fails for every caller even though the data permissions look complete.
+
+See sample: [`exposed-objects-must-be-in-a-permission-set.bad.al`](exposed-objects-must-be-in-a-permission-set.bad.al).
diff --git a/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md b/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md
index 4e9da1d..9a07747 100644
--- a/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md
+++ b/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md
@@ -15,7 +15,7 @@ It is tempting to flag any code that calls `GetLastErrorText()` and writes the r
## Best Practice
-When auditing AL changes for security, ignore patterns where `GetLastErrorText()` is captured into a table or shown to users β leave those to the privacy review. Security findings on error text should be limited to the construction of the `Error()` call itself: secrets, paths, or technical internals being interpolated into the error before it is raised. See sample: `getlasterrortext-storage-is-privacy-not-security.bad.al` for the pattern that is *not* a security finding.
+When auditing AL changes for security, ignore patterns where `GetLastErrorText()` is captured into a table or shown to users β leave those to the privacy review. Security findings on error text should be limited to the construction of the `Error()` call itself: secrets, paths, or technical internals being interpolated into the error before it is raised. See sample: [`getlasterrortext-storage-is-privacy-not-security.bad.al`](getlasterrortext-storage-is-privacy-not-security.bad.al) for the pattern that is *not* a security finding.
## Anti Pattern
diff --git a/microsoft/knowledge/security/guard-bulk-operations-with-istemporary.md b/microsoft/knowledge/security/guard-bulk-operations-with-istemporary.md
index 7cb53f8..3919f6b 100644
--- a/microsoft/knowledge/security/guard-bulk-operations-with-istemporary.md
+++ b/microsoft/knowledge/security/guard-bulk-operations-with-istemporary.md
@@ -19,10 +19,10 @@ An AL helper that accepts a `var Rec: Record X` parameter and performs a bulk op
Any helper designed to operate on a temporary record, and that performs `DeleteAll`, `ModifyAll`, or similar bulk writes on its parameter, should call `Rec.IsTemporary()` at the top and raise a descriptive error when the assumption is violated. The error message should name the parameter so the misuse is easy to locate.
-See sample: `guard-bulk-operations-with-istemporary.good.al`.
+See sample: [`guard-bulk-operations-with-istemporary.good.al`](guard-bulk-operations-with-istemporary.good.al).
## Anti Pattern
Trusting documentation or naming conventions alone to signal that a `var Rec` parameter is expected to be temporary. A future refactor or a copy-paste caller can pass the real table; the bulk operation then executes against production rows silently.
-See sample: `guard-bulk-operations-with-istemporary.bad.al`.
+See sample: [`guard-bulk-operations-with-istemporary.bad.al`](guard-bulk-operations-with-istemporary.bad.al).
diff --git a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al
index 9fef5e4..f69dbc3 100644
--- a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al
+++ b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al
@@ -1,4 +1,4 @@
permissionset 50203 "Sec Sample Report Runner"
{
- Permissions = tabledata "G/L Entry" = ri;
+ Permissions = tabledata "G/L Entry" = r;
}
diff --git a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md
index 206d211..8e3d620 100644
--- a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md
+++ b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md
@@ -1,7 +1,7 @@
---
bc-version: [all]
domain: security
-keywords: [permissionset, indirect-permissions, ri, ii, mi, di, code-mediated]
+keywords: [permissionset, indirect-permissions, lowercase, code-mediated]
technologies: [al]
countries: [w1]
application-area: [all]
@@ -15,8 +15,12 @@ In a `permissionset`, uppercase letters (`R`, `I`, `M`, `D`) grant **direct** pe
## Best Practice
-Use indirect permissions (`ri`, `ii`, `mi`, `di`) when a role needs access to a sensitive table only through a specific codeunit or report β for example, a "Report Runner" role that reads `G/L Entry` only via published reports. Pair the indirect grant with the codeunit or report that mediates access; that object's own permissions (or InherentPermissions) supply the direct rights. Document why indirect permissions are required in the permission set or in the consuming object's comments. See sample: `indirect-permissions-for-elevated-access.good.al`.
+Use indirect permissions (the lowercase letters `r`, `i`, `m`, `d`) when a role needs access to a sensitive table only through a specific codeunit or report β for example, a "Report Runner" role that reads `G/L Entry` only via published reports, granted `tabledata "G/L Entry" = r`. Each letter is one permission: `ri` grants indirect read **and** indirect insert, so grant only the letters the role needs. Pair the indirect grant with the codeunit or report that mediates access; that object's own permissions (or InherentPermissions) supply the direct rights. Document why indirect permissions are required in the permission set or in the consuming object's comments. See sample: [`indirect-permissions-for-elevated-access.good.al`](indirect-permissions-for-elevated-access.good.al).
## Anti Pattern
-Granting `RIMD` on a sensitive table when the role only needs to view it through a report β for example `tabledata "G/L Entry" = RIMD` on a "Report Runner" role. Users assigned that role can now query and modify ledger entries directly through any client that respects the permission, bypassing the report entirely. Reviewers should look for uppercase grants on system-of-record tables (G/L Entry, ledger entries, posted documents) where the consuming code path is clearly read-through-report or read-through-API. See sample: `indirect-permissions-for-elevated-access.bad.al`.
+Granting `RIMD` on a sensitive table when the role only needs to view it through a report β for example `tabledata "G/L Entry" = RIMD` on a "Report Runner" role. Users assigned that role can now query and modify ledger entries directly through any client that respects the permission, bypassing the report entirely. Reviewers should look for uppercase grants on system-of-record tables (G/L Entry, ledger entries, posted documents) where the consuming code path is clearly read-through-report or read-through-API. See sample: [`indirect-permissions-for-elevated-access.bad.al`](indirect-permissions-for-elevated-access.bad.al).
+
+## References
+
+[Permissions property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-permissions-property) and [Permissions on database objects](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-permissions-on-database-objects) define one letter per permission: `R`/`r` read, `I`/`i` insert, `M`/`m` modify, `D`/`d` delete, uppercase for direct and lowercase for indirect.
diff --git a/microsoft/knowledge/security/inherent-permissions-minimal-grant.md b/microsoft/knowledge/security/inherent-permissions-minimal-grant.md
index 41ba2a5..1a09194 100644
--- a/microsoft/knowledge/security/inherent-permissions-minimal-grant.md
+++ b/microsoft/knowledge/security/inherent-permissions-minimal-grant.md
@@ -15,8 +15,8 @@ application-area: [all]
## Best Practice
-Match the inherent permission to the procedure's body: a procedure that only reads `Customer.Name` declares `[InherentPermissions(PermissionObjectType::TableData, Database::Customer, 'r')]`, not `'RIMD'`. Pick the inherent entitlement that matches the lowest tier the procedure should run under β do not require Premium for a procedure that performs an Essential-tier check. See sample: `inherent-permissions-minimal-grant.good.al`.
+Match the inherent permission to the procedure's body: a procedure that only reads `Customer.Name` declares `[InherentPermissions(PermissionObjectType::TableData, Database::Customer, 'r')]`, not `'RIMD'`. Pick the inherent entitlement that matches the lowest tier the procedure should run under β do not require Premium for a procedure that performs an Essential-tier check. See sample: [`inherent-permissions-minimal-grant.good.al`](inherent-permissions-minimal-grant.good.al).
## Anti Pattern
-Declaring `[InherentPermissions(..., 'RIMD')]` on a read-only procedure (`GetCustomerName`), or `[InherentEntitlements(Entitlement::"Dynamics 365 Business Central Premium")]` on a procedure that performs a simple existence check. Reviewers should compare the attribute's permission letters against what the procedure body actually does and flag any grant broader than the operations performed. See sample: `inherent-permissions-minimal-grant.bad.al`.
+Declaring `[InherentPermissions(..., 'RIMD')]` on a read-only procedure (`GetCustomerName`), or `[InherentEntitlements(Entitlement::"Dynamics 365 Business Central Premium")]` on a procedure that performs a simple existence check. Reviewers should compare the attribute's permission letters against what the procedure body actually does and flag any grant broader than the operations performed. See sample: [`inherent-permissions-minimal-grant.bad.al`](inherent-permissions-minimal-grant.bad.al).
diff --git a/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md
index 1c51f8b..bd6a226 100644
--- a/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md
+++ b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md
@@ -15,8 +15,8 @@ application-area: [all]
## Best Practice
-Restrict event payloads to the non-sensitive context a subscriber legitimately needs: the business record being processed (a `Customer`), the operation being performed, an `IsHandled` flag that lets a subscriber skip the default behaviour, and a mutable payload object whose contents the publisher controls. Authentication is handled by the publisher before or after the event, never inside the parameters. See sample: `integrationevent-must-not-expose-secrets.good.al`.
+Restrict event payloads to the non-sensitive context a subscriber legitimately needs: the business record being processed (a `Customer`), the operation being performed, an `IsHandled` flag that lets a subscriber skip the default behaviour, and a mutable payload object whose contents the publisher controls. Authentication is handled by the publisher before or after the event, never inside the parameters. See sample: [`integrationevent-must-not-expose-secrets.good.al`](integrationevent-must-not-expose-secrets.good.al).
## Anti Pattern
-`[IntegrationEvent(false, false)] procedure OnBeforeSendRequest(var ApiKey: Text; var Password: Text; var RequestUrl: Text)` β any extension on the tenant can subscribe, read `ApiKey` and `Password`, and persist them elsewhere. Reviewers should flag any event parameter whose name or type suggests a secret (`ApiKey`, `Token`, `Password`, `Secret`, `Credential`, `SecretText` β even `SecretText` should not flow through an event surface). See sample: `integrationevent-must-not-expose-secrets.bad.al`.
+`[IntegrationEvent(false, false)] procedure OnBeforeSendRequest(var ApiKey: Text; var Password: Text; var RequestUrl: Text)` β any extension on the tenant can subscribe, read `ApiKey` and `Password`, and persist them elsewhere. Reviewers should flag any event parameter whose name or type suggests a secret (`ApiKey`, `Token`, `Password`, `Secret`, `Credential`, `SecretText` β even `SecretText` should not flow through an event surface). See sample: [`integrationevent-must-not-expose-secrets.bad.al`](integrationevent-must-not-expose-secrets.bad.al).
diff --git a/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md
index 3429e80..1edcb19 100644
--- a/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md
+++ b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md
@@ -15,8 +15,8 @@ A `var` parameter on an `[IntegrationEvent]` is a mutable hook: any subscriber c
## Best Practice
-Keep the security decision inside the publisher, where it is not bypassable. Fire an `OnAfter*` informational event after the check completes, with the result passed by value (not `var`) so subscribers can react β log, audit, surface a warning β but cannot rewrite the outcome. When subscribers legitimately need to add their own checks, expose an `OnAfterCheckPermissions(...)` that can only tighten access (e.g., a subscriber can `Error()`), never loosen it. See sample: `integrationevent-var-parameter-bypasses-security-guards.good.al`.
+Keep the security decision inside the publisher, where it is not bypassable. Fire an `OnAfter*` informational event after the check completes, with the result passed by value (not `var`) so subscribers can react β log, audit, surface a warning β but cannot rewrite the outcome. When subscribers legitimately need to add their own checks, expose an `OnAfterCheckPermissions(...)` that can only tighten access (e.g., a subscriber can `Error()`), never loosen it. See sample: [`integrationevent-var-parameter-bypasses-security-guards.good.al`](integrationevent-var-parameter-bypasses-security-guards.good.al).
## Anti Pattern
-`OnBeforeCheckPermissions(var HasAccess: Boolean; var SkipValidation: Boolean; TableNo: Integer)`, followed in the caller by `if SkipValidation then exit;`. Any subscriber sets `SkipValidation := true` and the check is gone. Reviewers should flag any `IntegrationEvent` whose signature contains a `var Boolean` whose name reads like a security decision (`HasAccess`, `IsAllowed`, `SkipValidation`, `BypassCheck`, `IsAuthorized`). See sample: `integrationevent-var-parameter-bypasses-security-guards.bad.al`.
+`OnBeforeCheckPermissions(var HasAccess: Boolean; var SkipValidation: Boolean; TableNo: Integer)`, followed in the caller by `if SkipValidation then exit;`. Any subscriber sets `SkipValidation := true` and the check is gone. Reviewers should flag any `IntegrationEvent` whose signature contains a `var Boolean` whose name reads like a security decision (`HasAccess`, `IsAllowed`, `SkipValidation`, `BypassCheck`, `IsAuthorized`). See sample: [`integrationevent-var-parameter-bypasses-security-guards.bad.al`](integrationevent-var-parameter-bypasses-security-guards.bad.al).
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
index bdd8462..8f57e22 100644
--- a/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.md
+++ b/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.md
@@ -17,10 +17,10 @@ application-area: [all]
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`.
+See sample: [`internal-access-is-not-a-security-boundary.good.al`](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`.
+See sample: [`internal-access-is-not-a-security-boundary.bad.al`](internal-access-is-not-a-security-boundary.bad.al).
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 d887d92..062c202 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
@@ -15,8 +15,8 @@ application-area: [all]
## Best Practice
-Mark every procedure that touches `IsolatedStorage` as `local` (visible only inside its containing object) or `internal` (visible only inside the owning extension). Provide consumers with a narrow, intent-specific API β for example, "send notification to configured webhook" rather than "give me the webhook secret." See sample: `isolatedstorage-access-must-be-local-or-internal.good.al`.
+Mark every procedure that touches `IsolatedStorage` as `local` (visible only inside its containing object) or `internal` (visible only inside the owning extension). Provide consumers with a narrow, intent-specific API β for example, "send notification to configured webhook" rather than "give me the webhook secret." See sample: [`isolatedstorage-access-must-be-local-or-internal.good.al`](isolatedstorage-access-must-be-local-or-internal.good.al).
## Anti Pattern
-A public `GetApiKey()` returning the stored value, or a public `SetApiKey(NewKey: Text)` that calls `IsolatedStorage.SetEncrypted`. Both turn the extension into a confused deputy that hands out (or accepts overwrites of) its own secrets on behalf of any caller on the tenant. Reviewers should flag any procedure whose body references `IsolatedStorage` and whose declaration omits `local` or `internal`. See sample: `isolatedstorage-access-must-be-local-or-internal.bad.al`.
+A public `GetApiKey()` returning the stored value, or a public `SetApiKey(NewKey: Text)` that calls `IsolatedStorage.SetEncrypted`. Both turn the extension into a confused deputy that hands out (or accepts overwrites of) its own secrets on behalf of any caller on the tenant. Reviewers should flag any procedure whose body references `IsolatedStorage` and whose declaration omits `local` or `internal`. See sample: [`isolatedstorage-access-must-be-local-or-internal.bad.al`](isolatedstorage-access-must-be-local-or-internal.bad.al).
diff --git a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md
index 111daf0..a91e91f 100644
--- a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md
+++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md
@@ -15,8 +15,8 @@ application-area: [all]
## Best Practice
-Choose `Module` when the secret is the same for every company and every user under the extension (a single tenant-wide API key). Choose `Company` when each company has its own integration credentials. Choose the user scope only when the secret is genuinely per-user. Use the same `DataScope` value on `Set`/`SetEncrypted`, `Get`, `Contains`, and `Delete` for the same key β mixing scopes for the same logical secret produces silent "not found" results. See sample: `isolatedstorage-datascope-module-vs-company.good.al`.
+Choose `Module` when the secret is the same for every company and every user under the extension (a single tenant-wide API key). Choose `Company` when each company has its own integration credentials. Choose the user scope only when the secret is genuinely per-user. Use the same `DataScope` value on `Set`/`SetEncrypted`, `Get`, `Contains`, and `Delete` for the same key β mixing scopes for the same logical secret produces silent "not found" results. See sample: [`isolatedstorage-datascope-module-vs-company.good.al`](isolatedstorage-datascope-module-vs-company.good.al).
## Anti Pattern
-Defaulting every call to `DataScope::Module` regardless of intent β storing a per-company webhook URL under `Module` means every company on the tenant shares the same URL. Or the inverse: storing a tenant-wide API key under `Company` means each company-switch effectively loses the key. Reviewers should look for cross-method inconsistency (`Set` under `Module`, `Get` under `Company`) and for scope choices that contradict the value's documented lifetime. See sample: `isolatedstorage-datascope-module-vs-company.bad.al`.
+Defaulting every call to `DataScope::Module` regardless of intent β storing a per-company webhook URL under `Module` means every company on the tenant shares the same URL. Or the inverse: storing a tenant-wide API key under `Company` means each company-switch effectively loses the key. Reviewers should look for cross-method inconsistency (`Set` under `Module`, `Get` under `Company`) and for scope choices that contradict the value's documented lifetime. See sample: [`isolatedstorage-datascope-module-vs-company.bad.al`](isolatedstorage-datascope-module-vs-company.bad.al).
diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md
index bf2019d..6f22129 100644
--- a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md
+++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md
@@ -15,8 +15,8 @@ application-area: [all]
## Best Practice
-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`.
+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`](isolatedstorage-setencrypted-for-sensitive-values.good.al).
## Anti Pattern
-`IsolatedStorage.Set('ApiKey', ApiKeyValue, DataScope::Module)` β the key is now sitting in storage unencrypted, and any future incident that exposes the underlying storage exposes the key. Reviewers should flag any `IsolatedStorage.Set` whose key name or surrounding context suggests a secret (`ApiKey`, `Token`, `Password`, `Secret`, `ClientSecret`). See sample: `isolatedstorage-setencrypted-for-sensitive-values.bad.al`.
+`IsolatedStorage.Set('ApiKey', ApiKeyValue, DataScope::Module)` β the key is now sitting in storage unencrypted, and any future incident that exposes the underlying storage exposes the key. Reviewers should flag any `IsolatedStorage.Set` whose key name or surrounding context suggests a secret (`ApiKey`, `Token`, `Password`, `Secret`, `ClientSecret`). See sample: [`isolatedstorage-setencrypted-for-sensitive-values.bad.al`](isolatedstorage-setencrypted-for-sensitive-values.bad.al).
diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md
index 2c5dce8..ae973a1 100644
--- a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md
+++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md
@@ -15,8 +15,8 @@ application-area: [all]
## Best Practice
-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`.
+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`](nondebuggable-required-when-unwrapping-secrettext.good.al).
## Anti Pattern
-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`.
+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`](nondebuggable-required-when-unwrapping-secrettext.bad.al).
diff --git a/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md
index 20332b2..200fe01 100644
--- a/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md
+++ b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md
@@ -15,8 +15,8 @@ A `permissionset` object can grant access object-by-object or with the `*` wildc
## Best Practice
-Enumerate each `tabledata` and each `table` entry explicitly. Grant only the letters required: `R` for read-only consumers, `RIM` for editors that do not delete, `RIMD` only for owners of the data. When a role needs Execute on objects, list those objects rather than using `table *`. See sample: `permission-set-avoid-wildcard-grants.good.al`.
+Enumerate each `tabledata` and each `table` entry explicitly. Grant only the letters required: `R` for read-only consumers, `RIM` for editors that do not delete, `RIMD` only for owners of the data. When a role needs Execute on objects, list those objects rather than using `table *`. See sample: [`permission-set-avoid-wildcard-grants.good.al`](permission-set-avoid-wildcard-grants.good.al).
## Anti Pattern
-`Permissions = tabledata * = RIMD;` and `Permissions = table * = X, tabledata * = R;` β both grant access to objects the role's author never inspected, and the grant silently broadens every time a new table ships in the platform or in another extension. Reviewers should flag any `*` on the left-hand side of a `tabledata` or `table` entry. See sample: `permission-set-avoid-wildcard-grants.bad.al`.
+`Permissions = tabledata * = RIMD;` and `Permissions = table * = X, tabledata * = R;` β both grant access to objects the role's author never inspected, and the grant silently broadens every time a new table ships in the platform or in another extension. Reviewers should flag any `*` on the left-hand side of a `tabledata` or `table` entry. See sample: [`permission-set-avoid-wildcard-grants.bad.al`](permission-set-avoid-wildcard-grants.bad.al).
diff --git a/microsoft/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
index 131bb9b..cf0db17 100644
--- a/microsoft/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
@@ -17,10 +17,10 @@ External HTTP integrations from AL can authenticate using OAuth 2.0 (client-cred
When the partner supports OAuth, use the platform `OAuth2` codeunit (`AcquireTokenWithClientCredentials` for service-to-service, `AcquireAuthorizationCodeTokenFromCache` for user-delegated flows) rather than hand-rolled token acquisition. Carry tokens and client secrets as `SecretText`, persist them only in IsolatedStorage, and refresh tokens proactively β on a buffer before the documented expiry β so routine calls never block on a token refresh.
-See sample: `prefer-oauth2-over-api-keys-for-external-http-calls.good.al`.
+See sample: [`prefer-oauth2-over-api-keys-for-external-http-calls.good.al`](prefer-oauth2-over-api-keys-for-external-http-calls.good.al).
## Anti Pattern
Accepting an API-key or basic-auth integration because it is the first option documented, even when the partner supports OAuth. The shared secret usually ends up in a setup-table `Text` field, rotation becomes a manual operation that rarely happens, and a single disclosure exposes every tenant using the extension.
-See sample: `prefer-oauth2-over-api-keys-for-external-http-calls.bad.al`.
+See sample: [`prefer-oauth2-over-api-keys-for-external-http-calls.bad.al`](prefer-oauth2-over-api-keys-for-external-http-calls.bad.al).
diff --git a/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.md b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.md
index 7eedc02..5f50156 100644
--- a/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.md
+++ b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.md
@@ -17,10 +17,10 @@ A temporary record copies data out of the source table into session memory. The
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`.
+See sample: [`protect-sensitive-data-in-temporary-tables.good.al`](protect-sensitive-data-in-temporary-tables.good.al).
## Anti Pattern
Copying records into a temporary buffer without a preceding permission check, and relying on procedure-exit to clean up. An exception before the explicit cleanup leaves the data in the buffer; a global or var-parameter buffer carries the data back to callers that may have no right to see it.
-See sample: `protect-sensitive-data-in-temporary-tables.bad.al`.
+See sample: [`protect-sensitive-data-in-temporary-tables.bad.al`](protect-sensitive-data-in-temporary-tables.bad.al).
diff --git a/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md
index c84e15a..7e8fb59 100644
--- a/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md
+++ b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md
@@ -15,8 +15,8 @@ When a codeunit holds permission to system tables β directly, via a permission
## Best Practice
-Mark such procedures `local` (callable only inside the containing object), `internal` (callable only inside the owning extension), or `[Scope('OnPrem')]` (not callable from SaaS extensions). If the procedure must be public, validate the table number against an allow-list before `RecordRef.Open` β `if not IsAllowedTable(RecId.TableNo) then Error(...)` β so the caller cannot specify an arbitrary table. See sample: `recordref-open-with-caller-table-must-not-be-public.good.al`.
+Mark such procedures `local` (callable only inside the containing object), `internal` (callable only inside the owning extension), or `[Scope('OnPrem')]` (not callable from SaaS extensions). If the procedure must be public, validate the table number against an allow-list before `RecordRef.Open` β `if not IsAllowedTable(RecId.TableNo) then Error(...)` β so the caller cannot specify an arbitrary table. See sample: [`recordref-open-with-caller-table-must-not-be-public.good.al`](recordref-open-with-caller-table-must-not-be-public.good.al).
## Anti Pattern
-`procedure ArchiveRecord(RecId: RecordId)` (public by default) whose body calls `RecRef.Open(RecId.TableNo)` and then reads, modifies, or deletes the record. Reviewers should flag any procedure that is public (no `local`/`internal`/`[Scope('OnPrem')]`), takes a `RecordId`, `Integer` table number, or `Variant` as a parameter, and calls `RecordRef.Open` with that parameter β unless an allow-list check on the table number precedes the open. See sample: `recordref-open-with-caller-table-must-not-be-public.bad.al`.
+`procedure ArchiveRecord(RecId: RecordId)` (public by default) whose body calls `RecRef.Open(RecId.TableNo)` and then reads, modifies, or deletes the record. Reviewers should flag any procedure that is public (no `local`/`internal`/`[Scope('OnPrem')]`), takes a `RecordId`, `Integer` table number, or `Variant` as a parameter, and calls `RecordRef.Open` with that parameter β unless an allow-list check on the table number precedes the open. See sample: [`recordref-open-with-caller-table-must-not-be-public.bad.al`](recordref-open-with-caller-table-must-not-be-public.bad.al).
diff --git a/microsoft/knowledge/security/secrets-isolated-storage.md b/microsoft/knowledge/security/secrets-isolated-storage.md
index 1434c1a..feb3c1c 100644
--- a/microsoft/knowledge/security/secrets-isolated-storage.md
+++ b/microsoft/knowledge/security/secrets-isolated-storage.md
@@ -17,10 +17,10 @@ API keys, OAuth tokens, client secrets, and connection strings must not be store
Persist every credential in `IsolatedStorage`, write it at the point of capture, and read it only when needed. Prefer `SetEncrypted` when the value fits its documented length limit. On BC24 and later, carry the value through the `SecretText` overloads; on earlier releases, keep any required `Text` handling inside a `[NonDebuggable]` boundary. Choose the `DataScope` that matches the credential's lifetime. See `isolatedstorage-datascope-module-vs-company`, `isolatedstorage-setencrypted-for-sensitive-values`, and `secrettext-for-credentials` for those separate concerns.
-See sample: `secrets-isolated-storage.good.al`.
+See sample: [`secrets-isolated-storage.good.al`](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`.
+See sample: [`secrets-isolated-storage.bad.al`](secrets-isolated-storage.bad.al).
diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md
index 4c0fa41..988d2a6 100644
--- a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md
+++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md
@@ -15,8 +15,8 @@ application-area: [all]
## Best Practice
-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`.
+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`](secretstrsubstno-for-composing-secrets.good.al).
## Anti Pattern
-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`.
+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`](secretstrsubstno-for-composing-secrets.bad.al).
diff --git a/microsoft/knowledge/security/secrettext-for-credentials.md b/microsoft/knowledge/security/secrettext-for-credentials.md
index 0eb6cb8..6f5d368 100644
--- a/microsoft/knowledge/security/secrettext-for-credentials.md
+++ b/microsoft/knowledge/security/secrettext-for-credentials.md
@@ -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 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`.
+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`](secrettext-for-credentials.good.al).
## Anti Pattern
-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`.
+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`](secrettext-for-credentials.bad.al).
diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.md b/microsoft/knowledge/security/secrettext-with-httpclient.md
index 689a7b6..1395563 100644
--- a/microsoft/knowledge/security/secrettext-with-httpclient.md
+++ b/microsoft/knowledge/security/secrettext-with-httpclient.md
@@ -15,8 +15,8 @@ The secret URI API belongs to `HttpRequestMessage`, not `HttpClient`. `HttpReque
## Best Practice
-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`.
+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`](secrettext-with-httpclient.good.al).
## Anti Pattern
-Holding a credential in `Text`, interpolating it with `StrSubstNo` or concatenation, and passing that plain text to `HttpClient.Get` or `HttpHeaders.Add`. The secret-aware request and header APIs remove the need to materialize the value as `Text`. This HTTP-sink rule supersedes the generic `secrettext-for-credentials.md` rule at the same location. 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`](secrettext-with-httpclient.bad.al).
diff --git a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.bad.al b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.bad.al
new file mode 100644
index 0000000..ee0d25e
--- /dev/null
+++ b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.bad.al
@@ -0,0 +1,23 @@
+codeunit 50541 "Sec Sample UnauthResp Bad"
+{
+ procedure IsVatNumberValid(RequestedCountryCode: Text; RequestedVatNumber: Text): Boolean
+ var
+ HttpClient: HttpClient;
+ Response: HttpResponseMessage;
+ JsonResponse: JsonObject;
+ JsonToken: JsonToken;
+ Content: Text;
+ begin
+ // Anti-pattern: the endpoint is unauthenticated, yet the response is trusted with no
+ // size cap, no schema check, and no request-to-response integrity check.
+ HttpClient.Get('http://vat-service.example/check?cc=' + RequestedCountryCode + '&vat=' + RequestedVatNumber, Response);
+ Response.Content().ReadAs(Content);
+ JsonResponse.ReadFrom(Content);
+
+ // Trusts valid=true for ANY input: a spoofed or MITM response that omits the echoed
+ // countryCode/vatNumber is accepted as valid for whatever number was requested.
+ if JsonResponse.Get('valid', JsonToken) then
+ exit(JsonToken.AsValue().AsBoolean());
+ exit(false);
+ end;
+}
diff --git a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.good.al b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.good.al
new file mode 100644
index 0000000..2c3e437
--- /dev/null
+++ b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.good.al
@@ -0,0 +1,46 @@
+codeunit 50540 "Sec Sample UnauthResp Good"
+{
+ // The public VAT validation service does not authenticate itself to us (no OAuth, no
+ // certificate, plain HTTP), so its response must be validated before it is trusted.
+ procedure IsVatNumberValid(RequestedCountryCode: Text; RequestedVatNumber: Text): Boolean
+ var
+ HttpClient: HttpClient;
+ Response: HttpResponseMessage;
+ JsonResponse: JsonObject;
+ JsonToken: JsonToken;
+ Content: Text;
+ ResponseCountryCode: Text;
+ ResponseVatNumber: Text;
+ begin
+ HttpClient.Get('http://vat-service.example/check?cc=' + RequestedCountryCode + '&vat=' + RequestedVatNumber, Response);
+ if not Response.IsSuccessStatusCode() then
+ exit(false);
+
+ Response.Content().ReadAs(Content);
+
+ // 1) Size cap - the platform already buffered the whole body; reject abnormally large payloads.
+ if StrLen(Content) > 4096 then
+ Error('The VAT validation response exceeded the maximum allowed size and was rejected.');
+
+ // 2) Schema - require the expected scalar fields, not just a truthy flag.
+ if not JsonResponse.ReadFrom(Content) then
+ Error('The VAT validation response was not in the expected format and was rejected.');
+ if not JsonResponse.Get('countryCode', JsonToken) then
+ Error('The VAT validation response did not include the requested identifiers and was rejected.');
+ ResponseCountryCode := JsonToken.AsValue().AsText();
+ if not JsonResponse.Get('vatNumber', JsonToken) then
+ Error('The VAT validation response did not include the requested identifiers and was rejected.');
+ ResponseVatNumber := JsonToken.AsValue().AsText();
+
+ // 3) Integrity - the echoed identifiers must match the request, so a valid=true payload
+ // with the identifiers stripped cannot be accepted for an arbitrary VAT number.
+ if (UpperCase(ResponseCountryCode) <> UpperCase(RequestedCountryCode)) or
+ (UpperCase(ResponseVatNumber) <> UpperCase(RequestedVatNumber))
+ then
+ Error('The VAT validation response did not match the requested identifiers and was rejected.');
+
+ if not JsonResponse.Get('valid', JsonToken) then
+ exit(false);
+ exit(JsonToken.AsValue().AsBoolean());
+ end;
+}
diff --git a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md
new file mode 100644
index 0000000..ca09d56
--- /dev/null
+++ b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md
@@ -0,0 +1,22 @@
+---
+bc-version: [all]
+domain: security
+keywords: [unauthenticated, ssrf, httpclient, soap, response-validation, integrity, size-limit, disablehttpscheck, temp-blob, vies]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Validate responses from unauthenticated endpoints before trusting them
+
+## Description
+
+When AL calls an external endpoint that does not authenticate *itself* to the client, the response is fully attacker-influenceable β cleartext MITM, a spoofed or compromised host, DNS/redirect games, or simply a misbehaving public service. Recognizing that a call is unauthenticated is the first review step, and the signals are BC-specific: a bare `HttpClient.Get`/`Post` with no `Authorization` header, no acquired OAuth token, and no client certificate; a SOAP request whose credentials are blank, such as `SOAP Web Service Request Mgt.SetGlobals(..., '', BlankSecretText)`; or any request issued after `DisableHttpsCheck()` over plain HTTP (for example the EU VIES VAT service, whose default endpoint is `http://`). Because the BC platform HTTP stack buffers the entire response body before AL is handed the stream or `Temp Blob`, the whole payload is already in memory by the time AL parses it β so the response must pass three checks in AL β **response size**, **schema compliance**, and **content integrity** β *before* any of it is written to tax, VAT, customer, or vendor tables.
+
+## Best Practice
+
+Before parsing or trusting a response from an unauthenticated endpoint, apply all three of these checks before the payload reaches business logic: (1) **Response size** β reject when the buffered `Temp Blob` length or `Content-Length` exceeds a small cap sized to the expected payload; (2) **Schema compliance** β require the specific scalar nodes/fields you expect in the expected shape, not merely "the body contains a truthy flag"; (3) **Content integrity** β when the protocol echoes the identifiers you queried (VIES echoes `countryCode`/`vatNumber`; a public-IP service echoes an IP string), require them to be present and to match the request, so a response carrying only `valid=true` cannot be accepted for an arbitrary input. On any failing check, raise an `Error` and record a security audit via `Audit Log.LogAuditMessage(...)` plus telemetry. See sample: [`validate-unauthenticated-response-before-use.good.al`](validate-unauthenticated-response-before-use.good.al). For validating the outbound target/host, see `validate-user-configurable-urls.md`; for authenticating outbound calls, see `prefer-oauth2-over-api-keys-for-external-http-calls.md`.
+
+## Anti Pattern
+
+Feeding the parsed response straight into business logic β load the XML/JSON, read a `valid` flag or an IP-shaped substring, then `Customer.Modify()` β trusting it purely because the HTTP call returned 2xx, with no size, shape, or echoed-identifier check. Reviewers should flag an unauthenticated outbound call (no `Authorization`/OAuth/cert, blank SOAP `SecretText`, or a request after `DisableHttpsCheck`) whose response is parsed and persisted without a preceding size cap, schema check, and request-to-response integrity check. Do NOT, however, demand a streaming or bounded read that aborts the transfer mid-download, nor a resolved-IP/DNS-rebinding check: the platform buffers the full body before AL sees it and AL has no connection-time or DNS hook, so an in-AL size check necessarily runs after buffering and host-rebinding defense belongs to the platform egress layer β raising those is a false positive. HTTPS is likewise not always enforceable (VIES is HTTP by design); the mitigation there is response validation, not scheme enforcement. See sample: [`validate-unauthenticated-response-before-use.bad.al`](validate-unauthenticated-response-before-use.bad.al).
diff --git a/microsoft/knowledge/security/validate-user-configurable-urls.md b/microsoft/knowledge/security/validate-user-configurable-urls.md
index 06cc31d..8f79b04 100644
--- a/microsoft/knowledge/security/validate-user-configurable-urls.md
+++ b/microsoft/knowledge/security/validate-user-configurable-urls.md
@@ -15,8 +15,8 @@ A URL stored in a table field is user-configurable: anyone with write access to
## Best Practice
-Before any `HttpClient` call whose URL came from a table field, call `Uri.AreURIsHaveSameHost(StoredUrl, ExpectedBaseUrl)` against a hard-coded expected base, or `Uri.IsValidURIPattern(StoredUrl, 'https://*.myshopify.com/*')` against a fixed pattern. Fail the call with an `Error` when the validator returns false. For webhook scenarios where the host is registered out-of-band, compare against the registered host stored alongside the URL. See sample: `validate-user-configurable-urls.good.al`.
+Before any `HttpClient` call whose URL came from a table field, call `Uri.AreURIsHaveSameHost(StoredUrl, ExpectedBaseUrl)` against a hard-coded expected base, or `Uri.IsValidURIPattern(StoredUrl, 'https://*.myshopify.com/*')` against a fixed pattern. Fail the call with an `Error` when the validator returns false. For webhook scenarios where the host is registered out-of-band, compare against the registered host stored alongside the URL. See sample: [`validate-user-configurable-urls.good.al`](validate-user-configurable-urls.good.al).
## Anti Pattern
-`HttpClient.Get(Setup."Service URL", Response)` or `HttpClient.Post(WebhookSetup."Callback URL", Content, Response)` with no validation step in between. The extension will dutifully send the request β and any sensitive payload β to whatever host the attacker put in the field. Reviewers should flag any `HttpClient` call whose first argument is a record field, an `OnValidate`-mutable field, or a value sourced from a table read, unless a `Uri.AreURIsHaveSameHost` or `Uri.IsValidURIPattern` check precedes it. See sample: `validate-user-configurable-urls.bad.al`.
+`HttpClient.Get(Setup."Service URL", Response)` or `HttpClient.Post(WebhookSetup."Callback URL", Content, Response)` with no validation step in between. The extension will dutifully send the request β and any sensitive payload β to whatever host the attacker put in the field. Reviewers should flag any `HttpClient` call whose first argument is a record field, an `OnValidate`-mutable field, or a value sourced from a table read, unless a `Uri.AreURIsHaveSameHost` or `Uri.IsValidURIPattern` check precedes it. See sample: [`validate-user-configurable-urls.bad.al`](validate-user-configurable-urls.bad.al).
diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md
index d143ea5..6a3a4f4 100644
--- a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md
+++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md
@@ -15,8 +15,8 @@ application-area: [all]
## Best Practice
-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`.
+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`](validatetablerelation-false-on-user-input.good.al).
## Anti Pattern
-`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`.
+`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`](validatetablerelation-false-on-user-input.bad.al).
diff --git a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md
index edefcb6..2ab9383 100644
--- a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md
+++ b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md
@@ -19,10 +19,10 @@ The reviewer signal is "this is a new top-level card or list page in an app whos
Set `AboutTitle` and `AboutText` on every new top-level card, list, and document page in an app that already uses them. Keep `AboutText` to two or three short sentences. Describe what the page does, not the navigation steps to use it β teaching tips explain WHAT, not HOW.
-See sample: `abouttitle-abouttext-teaching-tips.good.al`.
+See sample: [`abouttitle-abouttext-teaching-tips.good.al`](abouttitle-abouttext-teaching-tips.good.al).
## Anti Pattern
A new top-level page in an app whose siblings have `AboutTitle`/`AboutText`, but with no teaching tips defined. Equally wrong is filling `AboutText` with step-by-step instructions ("Click New, then enterβ¦") β the property is for orientation, not procedural help.
-See sample: `abouttitle-abouttext-teaching-tips.bad.al`.
+See sample: [`abouttitle-abouttext-teaching-tips.bad.al`](abouttitle-abouttext-teaching-tips.bad.al).
diff --git a/microsoft/knowledge/style/al-build-output-must-not-pollute-project-root.md b/microsoft/knowledge/style/al-build-output-must-not-pollute-project-root.md
new file mode 100644
index 0000000..4e5a57a
--- /dev/null
+++ b/microsoft/knowledge/style/al-build-output-must-not-pollute-project-root.md
@@ -0,0 +1,24 @@
+---
+bc-version: [all]
+domain: style
+keywords: [build, output, alpackages, artifact-hygiene, outfolder, project-root]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Write AL Build Artifacts to an Intentional Output Location
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+
+An AL project's compiled `.app` file can be written to the project root by default, and current tooling explicitly supports choosing a different destination instead β `ALTool`'s `--outfolder` option and the `al_build` agent tool's `outputPath` parameter both exist for this. The problem this rule addresses is not that root-level output is technically invalid; it is agents leaving generated `.app` files scattered through arbitrary source locations, or treating a compiled artefact as if it were part of the source tree (committing it, editing around it, referencing it as a dependency by hand).
+
+## Best Practice
+
+Write build artifacts to a deliberate, dedicated output location β configured via the build tool actually in use (e.g. `ALTool --outfolder`, or an explicit `outputPath` on the agent build tool) β and add that folder to `.gitignore`. Treat a compiled `.app` as a build artifact, never as a source file to commit or hand-edit around.
+
+## Anti Pattern
+
+Letting `.app` files accumulate in arbitrary or unversioned locations without a deliberate output path, or committing compiled artefacts into source control alongside the AL files that produced them.
diff --git a/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.bad.al b/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.bad.al
new file mode 100644
index 0000000..869e398
--- /dev/null
+++ b/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.bad.al
@@ -0,0 +1,18 @@
+codeunit 50101 "Credit Memo Routing"
+{
+ procedure PostSalesLine(var SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line"; CustomerNo: Code[20]; Amount: Decimal)
+ begin
+ // Set the customer number
+ SalesHeader.Validate("Sell-to Customer No.", CustomerNo);
+ // Insert the line
+ SalesLine.Insert(true);
+ // Check if the amount is positive
+ if Amount > 0 then
+ // Post the entry
+ PostEntry(Amount);
+ end;
+
+ local procedure PostEntry(Amount: Decimal)
+ begin
+ end;
+}
diff --git a/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.good.al b/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.good.al
new file mode 100644
index 0000000..6bd39b1
--- /dev/null
+++ b/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.good.al
@@ -0,0 +1,20 @@
+codeunit 50101 "Credit Memo Routing"
+{
+ procedure PostSalesLine(var SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line"; CustomerNo: Code[20]; Amount: Decimal)
+ begin
+ SalesHeader.Validate("Sell-to Customer No.", CustomerNo);
+ SalesLine.Insert(true);
+
+ // Negative amounts arrive from credit memos routed through this
+ // codeunit; PostEntry() rejects them, so they're filtered here.
+ if Amount < 0 then
+ exit;
+
+ if Amount > 0 then
+ PostEntry(Amount);
+ end;
+
+ local procedure PostEntry(Amount: Decimal)
+ begin
+ end;
+}
diff --git a/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.md b/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.md
new file mode 100644
index 0000000..52c3c0a
--- /dev/null
+++ b/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: style
+keywords: [comments, verbosity, self-documenting, restate, tutorial-style, credit-memo-routing]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Comments must not restate what the code already shows
+
+## Description
+
+A comment above nearly every statement that just narrates what the statement already says (`// Validate the customer number` above `SalesHeader.Validate("Sell-to Customer No.", CustomerNo)`) adds noise without adding information. Production AL β the Base Application, mature partner codebases β is comment-sparse by comparison: identifiers do the explaining, and a comment appears only when the code alone can't carry the reason.
+
+A comment earns its place only when it captures something the code cannot: a non-obvious business rule, a workaround for a specific platform limitation, or a constraint that would surprise the next reader. If removing the comment would leave the reader no worse off, the comment should not have been written.
+
+This does not override required structural documentation β feature/scenario test tags and XML-doc summaries on public library procedures remain required where they apply; those are structural markers, not narrative comments.
+
+## Best Practice
+
+Let the code speak for itself; reserve comments for the reason a reader could not otherwise infer.
+
+See sample: [`al-comments-must-not-restate-what-code-already-shows.good.al`](al-comments-must-not-restate-what-code-already-shows.good.al).
+
+## Anti Pattern
+
+A comment line before every statement, repeating in English what the statement's own identifiers already say.
+
+See sample: [`al-comments-must-not-restate-what-code-already-shows.bad.al`](al-comments-must-not-restate-what-code-already-shows.bad.al).
diff --git a/microsoft/knowledge/style/al-identifiers-english.bad.al b/microsoft/knowledge/style/al-identifiers-english.bad.al
new file mode 100644
index 0000000..e4d62da
--- /dev/null
+++ b/microsoft/knowledge/style/al-identifiers-english.bad.al
@@ -0,0 +1,11 @@
+codeunit 50100 "Sales Amount Calculator"
+{
+ procedure BeregnTotalbeloeb(var Salgslinje: Record "Sales Line"): Decimal
+ var
+ Beloeb: Decimal;
+ begin
+ Salgslinje.CalcSums(Amount);
+ Beloeb := Salgslinje.Amount;
+ exit(Beloeb);
+ end;
+}
diff --git a/microsoft/knowledge/style/al-identifiers-english.good.al b/microsoft/knowledge/style/al-identifiers-english.good.al
new file mode 100644
index 0000000..0b17ec1
--- /dev/null
+++ b/microsoft/knowledge/style/al-identifiers-english.good.al
@@ -0,0 +1,11 @@
+codeunit 50100 "Sales Amount Calculator"
+{
+ procedure CalculateTotalAmount(var SalesLine: Record "Sales Line"): Decimal
+ var
+ TotalAmount: Decimal;
+ begin
+ SalesLine.CalcSums(Amount);
+ TotalAmount := SalesLine.Amount;
+ exit(TotalAmount);
+ end;
+}
diff --git a/microsoft/knowledge/style/al-identifiers-english.md b/microsoft/knowledge/style/al-identifiers-english.md
new file mode 100644
index 0000000..5feda9f
--- /dev/null
+++ b/microsoft/knowledge/style/al-identifiers-english.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: style
+keywords: [identifiers, naming, english, captions, translation]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Write AL Identifiers in English Only
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+
+All AL identifiers β variables, procedures, parameters, fields, object names, enum values, and label identifiers β must be written in English, regardless of the developer's native language. Translations are handled separately through captions, tooltips, and XLIFF files, never by writing Danish, German, or other non-English identifiers directly into AL source code. This is not a stylistic preference: the public `microsoft/BCApps` codebase, maintained by engineers of many nationalities across hundreds of thousands of lines of AL, uses exclusively English identifiers, with all localization handled via caption properties and XLIFF rather than by changing identifier names. On any multi-contributor codebase, non-English identifiers make the code unreadable to contributors who don't share that language.
+
+## Best Practice
+
+Write every identifier in English, and translate developer intent rather than transliterating it β when a requirement is described in another language, the resulting variable, procedure, and field names should still read as English. Captions and tooltips may carry target-language text in the source file, with locale translations managed through XLIFF.
+
+See sample: [`al-identifiers-english.good.al`](al-identifiers-english.good.al).
+
+## Anti Pattern
+
+Using native-language identifiers such as a Danish variable or procedure name in AL source code, relying on the fact that the code still compiles and runs correctly. This makes the code unreadable to non-native-language contributors and mixes localization concerns into source that should stay language-neutral.
+
+See sample: [`al-identifiers-english.bad.al`](al-identifiers-english.bad.al).
diff --git a/microsoft/knowledge/style/api-page-camelcase-properties.md b/microsoft/knowledge/style/api-page-camelcase-properties.md
index 3baa009..41a812e 100644
--- a/microsoft/knowledge/style/api-page-camelcase-properties.md
+++ b/microsoft/knowledge/style/api-page-camelcase-properties.md
@@ -17,10 +17,10 @@ API pages β pages declared with `PageType = API` β surface as OData/JSON end
Pick camelCase identifiers up front: `APIPublisher = 'contoso'`, `APIGroup = 'app1'`, `EntityName = 'customer'`, field `Name = 'displayName'`. Keep them short β they end up in URL paths and JSON keys that every consumer types.
-See sample: `api-page-camelcase-properties.good.al`.
+See sample: [`api-page-camelcase-properties.good.al`](api-page-camelcase-properties.good.al).
## Anti Pattern
`APIPublisher = 'Contoso-App'` (hyphen rejected, capitalization wrong for camelCase), `EntityName = 'sales_order'` (underscore rejected), or fields exposed with `Name = 'Display Name'` (space rejected). The compiler usually catches these, but the failure mode is opaque and the rename cost on a deployed API is high.
-See sample: `api-page-camelcase-properties.bad.al`.
+See sample: [`api-page-camelcase-properties.bad.al`](api-page-camelcase-properties.bad.al).
diff --git a/microsoft/knowledge/style/api-page-delayedinsert-true.md b/microsoft/knowledge/style/api-page-delayedinsert-true.md
index 6045c2f..abe3fd6 100644
--- a/microsoft/knowledge/style/api-page-delayedinsert-true.md
+++ b/microsoft/knowledge/style/api-page-delayedinsert-true.md
@@ -17,10 +17,10 @@ On a normal page, `DelayedInsert = false` is the default: the record is inserted
Declare `DelayedInsert = true` on every page with `PageType = API`. The setting plays well with `Modify(true)` and `Insert(true)` calls inside `OnInsert` and avoids the half-populated record states that otherwise reach validation logic.
-See sample: `api-page-delayedinsert-true.good.al`.
+See sample: [`api-page-delayedinsert-true.good.al`](api-page-delayedinsert-true.good.al).
## Anti Pattern
Omitting `DelayedInsert` (which defaults to `false`) on an API page. Validation triggers fire on a partially populated record, mandatory-field errors come back to the caller for fields the JSON payload was about to supply, and the API surface produces failures that have no analogue in the UI page model.
-See sample: `api-page-delayedinsert-true.bad.al`.
+See sample: [`api-page-delayedinsert-true.bad.al`](api-page-delayedinsert-true.bad.al).
diff --git a/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md
index 6ba698e..48ce0cf 100644
--- a/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md
+++ b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md
@@ -17,10 +17,10 @@ application-area: [all]
Pick the singular noun for `EntityName` and its grammatical plural for `EntitySetName`, both in camelCase. For compound nouns, only the trailing noun is pluralized: `EntityName = 'salesOrder'`, `EntitySetName = 'salesOrders'`. For nouns whose plural is irregular, use the natural English form β `EntitySetName = 'people'` for `EntityName = 'person'`.
-See sample: `api-page-entity-naming-singular-plural.good.al`.
+See sample: [`api-page-entity-naming-singular-plural.good.al`](api-page-entity-naming-singular-plural.good.al).
## Anti Pattern
`EntityName = 'customers'`, `EntitySetName = 'customer'` β singular and plural swapped. Equally wrong is reusing the same form for both β `EntityName = 'customer'`, `EntitySetName = 'customer'` β which breaks OData metadata parsers and client codegen.
-See sample: `api-page-entity-naming-singular-plural.bad.al`.
+See sample: [`api-page-entity-naming-singular-plural.bad.al`](api-page-entity-naming-singular-plural.bad.al).
diff --git a/microsoft/knowledge/style/api-page-version-format.md b/microsoft/knowledge/style/api-page-version-format.md
index 633c53b..3bb3a03 100644
--- a/microsoft/knowledge/style/api-page-version-format.md
+++ b/microsoft/knowledge/style/api-page-version-format.md
@@ -17,10 +17,10 @@ The `APIVersion` property on an API page is part of the public URL path: `/api/<
Start a new public endpoint at `'v1.0'`. Bump the minor when adding fields or non-breaking changes; bump the major when changing field types, removing fields, or any breaking change. Use `'beta'` for endpoints that are still iterating and SHOULD NOT be consumed by external integrations.
-See sample: `api-page-version-format.good.al`.
+See sample: [`api-page-version-format.good.al`](api-page-version-format.good.al).
## Anti Pattern
`APIVersion = 'v2'` (missing minor), `APIVersion = '2.0'` (missing `v` prefix), `APIVersion = 'v2.0.0'` (extra segment). All three either fail to compile or produce a URL that consumers cannot reach.
-See sample: `api-page-version-format.bad.al`.
+See sample: [`api-page-version-format.bad.al`](api-page-version-format.bad.al).
diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al b/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al
deleted file mode 100644
index 8da7777..0000000
--- a/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al
+++ /dev/null
@@ -1,25 +0,0 @@
-page 50375 "Sample App Area Bad"
-{
- PageType = Card;
- SourceTable = Customer;
- layout
- {
- area(Content)
- {
- group(General)
- {
- // Anti-pattern: no ApplicationArea. AS0062 flags this control,
- // and it is silently hidden in the Web client for profiles whose
- // enabled areas do not already cover it.
- field("No."; Rec."No.")
- {
- ToolTip = 'Specifies the number that identifies the customer.';
- }
- field(Name; Rec.Name)
- {
- ToolTip = 'Specifies the customer''s name.';
- }
- }
- }
- }
-}
diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al b/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al
deleted file mode 100644
index d344337..0000000
--- a/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al
+++ /dev/null
@@ -1,40 +0,0 @@
-page 50374 "Sample App Area Good"
-{
- PageType = Card;
- SourceTable = Customer;
- layout
- {
- area(Content)
- {
- group(General)
- {
- field("No."; Rec."No.")
- {
- ApplicationArea = All;
- ToolTip = 'Specifies the number that identifies the customer.';
- }
- field(Name; Rec.Name)
- {
- ApplicationArea = All;
- ToolTip = 'Specifies the customer''s name.';
- }
- }
- }
- }
- actions
- {
- area(Processing)
- {
- action(Refresh)
- {
- ApplicationArea = All;
- ToolTip = 'Reloads the current record.';
-
- trigger OnAction()
- begin
- CurrPage.Update(false);
- end;
- }
- }
- }
-}
diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.md b/microsoft/knowledge/style/applicationarea-required-on-page-controls.md
deleted file mode 100644
index 606e08a..0000000
--- a/microsoft/knowledge/style/applicationarea-required-on-page-controls.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [application-area, page-control, as0062, appsourcecop, hidden-control, web-client]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Every page control needs an `ApplicationArea` (AppSourceCop AS0062)
-
-## Description
-
-A field control on a page or pageextension that has no `ApplicationArea` property is silently hidden in the Web client for every profile whose enabled application areas do not cover it. There is no error and no warning at runtime β the field simply does not appear, which reads as data loss to the user. AppSourceCop AS0062 flags any page control or action that is missing the `ApplicationArea` property, and AppSource technical validation rejects the app until it is set.
-
-Set the property to an area the app actually enables. `All` makes the control visible under every profile and is the common default; if the app declares narrower areas in `app.json`, use one of those. The property applies to field controls and to actions. This is a sibling concern to `caption-required-on-page-fields.md` and `tooltip-required-on-page-fields.md`; note that the ToolTip requirement is the separate CodeCop rule AA0218, not AS0062.
-
-## Best Practice
-
-Every field control and action carries `ApplicationArea = All;` (or a declared area of the app). The value is set once per control and keeps the control visible in the Web client.
-
-See sample: `applicationarea-required-on-page-controls.good.al`.
-
-## Anti Pattern
-
-A field control with no `ApplicationArea`. AS0062 flags it, and the control is invisible in the Web client for any profile that does not already enable a matching area.
-
-See sample: `applicationarea-required-on-page-controls.bad.al`.
diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al
deleted file mode 100644
index fd3ac45..0000000
--- a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al
+++ /dev/null
@@ -1,14 +0,0 @@
-codeunit 50235 "Sample Begin Own Line Bad"
-{
- procedure Run(Condition: Boolean)
- begin
- if Condition then
- begin
- DoSomething();
- DoSomethingElse();
- end;
- end;
-
- local procedure DoSomething() begin end;
- local procedure DoSomethingElse() begin end;
-}
diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al
deleted file mode 100644
index 6043c5b..0000000
--- a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al
+++ /dev/null
@@ -1,24 +0,0 @@
-codeunit 50234 "Sample Begin Same Line Good"
-{
- procedure Run(Condition: Boolean)
- var
- i: Integer;
- begin
- if Condition then begin
- DoSomething();
- DoSomethingElse();
- end else begin
- Reset();
- Notify();
- end;
- for i := 1 to 10 do begin
- DoSomething();
- DoSomethingElse();
- end;
- end;
-
- local procedure DoSomething() begin end;
- local procedure DoSomethingElse() begin end;
- local procedure Reset() begin end;
- local procedure Notify() begin end;
-}
diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md
deleted file mode 100644
index fbf7aec..0000000
--- a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [begin, end, compound-statement, aa0005, codecop, formatting]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# `begin` goes on the same line as `then`, `else`, or `do` (CodeCop AA0005)
-
-## Description
-
-When a compound block follows `then`, `else`, or `do`, the `begin` keyword must sit on the same line as the preceding keyword, separated by exactly one space. `if Condition then begin` and `for i := 1 to N do begin` are correct. The form that puts `begin` on its own line β common in older AL and in languages like Pascal β is flagged by CodeCop AA0005. The rule does not change indentation of the block body; it only governs the placement of `begin` relative to `then`/`else`/`do`.
-
-## Best Practice
-
-`if Condition then begin β¦ end;`, `else begin β¦ end;`, `for i := 1 to N do begin β¦ end;`. The block body is indented one level below the `if`/`for` line, and `end;` sits at the same indentation as the line that opened the block.
-
-See sample: `begin-on-same-line-as-then-else-do.good.al`.
-
-## Anti Pattern
-
-A line that ends with `then` (or `else`, or `do`) and is followed by a line whose only content is `begin`. The compiler accepts it but CodeCop AA0005 flags it; the visual cost is a wasted line per block and a layout that looks alien to readers used to current AL style.
-
-See sample: `begin-on-same-line-as-then-else-do.bad.al`.
diff --git a/microsoft/knowledge/style/binary-choice-must-be-boolean.bad.al b/microsoft/knowledge/style/binary-choice-must-be-boolean.bad.al
new file mode 100644
index 0000000..2201ea3
--- /dev/null
+++ b/microsoft/knowledge/style/binary-choice-must-be-boolean.bad.al
@@ -0,0 +1,22 @@
+table 50100 "Sales Task"
+{
+ fields
+ {
+ field(1; "No."; Code[20]) { }
+ field(10; Status; Option)
+ {
+ OptionMembers = Active,Blocked;
+ }
+ }
+}
+
+codeunit 50100 "Sales Task Check"
+{
+ procedure IsOverdue(DueDate: Date): Integer
+ begin
+ // 0 = No, 1 = Yes
+ if DueDate < Today then
+ exit(1);
+ exit(0);
+ end;
+}
diff --git a/microsoft/knowledge/style/binary-choice-must-be-boolean.good.al b/microsoft/knowledge/style/binary-choice-must-be-boolean.good.al
new file mode 100644
index 0000000..ac62b0f
--- /dev/null
+++ b/microsoft/knowledge/style/binary-choice-must-be-boolean.good.al
@@ -0,0 +1,16 @@
+table 50100 "Sales Task"
+{
+ fields
+ {
+ field(1; "No."; Code[20]) { }
+ field(10; Blocked; Boolean) { }
+ }
+}
+
+codeunit 50100 "Sales Task Check"
+{
+ procedure IsOverdue(DueDate: Date): Boolean
+ begin
+ exit(DueDate < Today);
+ end;
+}
diff --git a/microsoft/knowledge/style/binary-choice-must-be-boolean.md b/microsoft/knowledge/style/binary-choice-must-be-boolean.md
new file mode 100644
index 0000000..ec0c4a5
--- /dev/null
+++ b/microsoft/knowledge/style/binary-choice-must-be-boolean.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: style
+keywords: [boolean, option, yes-no, magic-number, variable-typing, field-typing]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Binary yes/no choices must be typed as Boolean, not Option or Integer
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+
+When a field or variable represents a genuine true/false state β yes/no, on/off, active/inactive, blocked/not blocked β it should be typed `Boolean`. Modeling that same predicate as an `Option`/`Enum` with two members, or as an `Integer` with two magic-number values, adds a layer of indirection a reader has to resolve before understanding the code. This is about semantics, not member count: a domain concept that currently has exactly two named alternatives β Inbound/Outbound, Debit/Credit, Buy/Sell β is not automatically a Boolean in disguise. An `Enum` can be the clearer model there, including when it needs to implement an interface, preserve an existing contract, or leave room for a future third value. The distinction is whether the domain is genuinely a stable predicate, not how many states it currently has.
+
+## Best Practice
+
+Type a field or variable as `Boolean` when the domain concept is inherently a true/false state. Do not replace a meaningful two-option domain model with a Boolean solely because it currently has two values.
+
+See sample: [`binary-choice-must-be-boolean.good.al`](binary-choice-must-be-boolean.good.al).
+
+## Anti Pattern
+
+Modeling a yes/no choice as an `Option` with two members, or as an `Integer` with magic-number values, forces every caller to remember which value means what and leaves room for a meaningless third value.
+
+See sample: [`binary-choice-must-be-boolean.bad.al`](binary-choice-must-be-boolean.bad.al).
diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.bad.al b/microsoft/knowledge/style/block-keywords-start-new-line.bad.al
deleted file mode 100644
index ef7f937..0000000
--- a/microsoft/knowledge/style/block-keywords-start-new-line.bad.al
+++ /dev/null
@@ -1,15 +0,0 @@
-codeunit 50239 "Sample Block Kw Bad"
-{
- procedure Dispatch(IsContactName: Boolean; IsSalespersonCode: Boolean)
- var
- i: Integer;
- begin
- if IsContactName then ValidateContactName() else if IsSalespersonCode then ValidateSalespersonCode();
- for i := 1 to 10 do begin DoSomething(i); DoSomethingElse(i); end;
- end;
-
- local procedure ValidateContactName() begin end;
- local procedure ValidateSalespersonCode() begin end;
- local procedure DoSomething(I: Integer) begin end;
- local procedure DoSomethingElse(I: Integer) begin end;
-}
diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.good.al b/microsoft/knowledge/style/block-keywords-start-new-line.good.al
deleted file mode 100644
index eb6c3d4..0000000
--- a/microsoft/knowledge/style/block-keywords-start-new-line.good.al
+++ /dev/null
@@ -1,23 +0,0 @@
-codeunit 50238 "Sample Block Kw Good"
-{
- procedure Dispatch(IsContactName: Boolean; IsSalespersonCode: Boolean)
- var
- i: Integer;
- begin
- if IsContactName then
- ValidateContactName()
- else
- if IsSalespersonCode then
- ValidateSalespersonCode();
-
- for i := 1 to 10 do begin
- DoSomething(i);
- DoSomethingElse(i);
- end;
- end;
-
- local procedure ValidateContactName() begin end;
- local procedure ValidateSalespersonCode() begin end;
- local procedure DoSomething(I: Integer) begin end;
- local procedure DoSomethingElse(I: Integer) begin end;
-}
diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.md b/microsoft/knowledge/style/block-keywords-start-new-line.md
deleted file mode 100644
index d40ea61..0000000
--- a/microsoft/knowledge/style/block-keywords-start-new-line.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [block-keyword, end, if, repeat, until, for, while, case, aa0018]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Block keywords (`end`, `if`, `repeat`, `until`, `for`, `while`, `case`) start a new line (CodeCop AA0018)
-
-## Description
-
-CodeCop AA0018 requires that the block-introducing keywords `if`, `repeat`, `until`, `for`, `while`, `case`, and the block-terminating keyword `end` always start a new line. Multiple statements packed onto one line β `if A then X() else if B then Y();` written inline, or `for i := 1 to 10 do begin X(i); Y(i); end;` β defeat code review tooling that operates line-by-line and obscure the control flow. The rule does not prohibit short single-statement constructs spread across two lines (`if Cond then X();`); it prohibits packing the entire control structure onto one line.
-
-## Best Practice
-
-Each `if`, `else if`, `repeat`, `for`, `while`, and `case` starts a line. Each `end;` (the closing of a `begin β¦ end` block or a `case`) starts a line. Branch bodies are on their own line, indented.
-
-See sample: `block-keywords-start-new-line.good.al`.
-
-## Anti Pattern
-
-`if IsContactName then ValidateContactName() else if IsSalespersonCode then ValidateSalespersonCode();` collapses an `if/else if` chain onto a single line; AA0018 flags both the `else` and the second `if`. The same applies to `for i := 1 to 10 do begin DoX(i); DoY(i); end;` β `end` is not at the start of its line.
-
-See sample: `block-keywords-start-new-line.bad.al`.
diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.bad.al b/microsoft/knowledge/style/caption-required-on-page-fields.bad.al
index fd458a4..21dccb3 100644
--- a/microsoft/knowledge/style/caption-required-on-page-fields.bad.al
+++ b/microsoft/knowledge/style/caption-required-on-page-fields.bad.al
@@ -9,16 +9,22 @@ page 50253 "Sample Caption Bad"
{
group(General)
{
- field("Customer No."; Rec."No.")
+ Caption = 'General';
+ field(CustomerNoValue; CustomerNoValue)
{
ApplicationArea = All;
+ ToolTip = 'Specifies the customer number to look up.';
}
field("Customer Name"; Rec.Name)
{
ApplicationArea = All;
Caption = '';
+ ToolTip = 'Specifies the customer name shown on sales documents.';
}
}
}
}
+
+ var
+ CustomerNoValue: Code[20];
}
diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.good.al b/microsoft/knowledge/style/caption-required-on-page-fields.good.al
index 0493b6e..f91ed73 100644
--- a/microsoft/knowledge/style/caption-required-on-page-fields.good.al
+++ b/microsoft/knowledge/style/caption-required-on-page-fields.good.al
@@ -1,7 +1,36 @@
+// BC24 / runtime 13.0 or later for table-field tooltips.
+table 50252 "Sample Caption Source"
+{
+ Caption = 'Caption Source';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20])
+ {
+ Caption = 'No.';
+ ToolTip = 'Specifies the unique number used to distinguish this customer record from other records.';
+ }
+ field(2; Name; Text[100])
+ {
+ Caption = 'Name';
+ ToolTip = 'Specifies the name used to identify the customer alongside the unique customer number.';
+ }
+ }
+
+ keys
+ {
+ key(PK; "No.")
+ {
+ Clustered = true;
+ }
+ }
+}
+
page 50252 "Sample Caption Good"
{
PageType = Card;
- SourceTable = Customer;
+ SourceTable = "Sample Caption Source";
layout
{
@@ -10,19 +39,25 @@ page 50252 "Sample Caption Good"
group(General)
{
Caption = 'General';
- field("Customer No."; Rec."No.")
+ field("No."; Rec."No.")
{
ApplicationArea = All;
- Caption = 'Customer No.';
- ToolTip = 'Specifies the customer number.';
}
field("Customer Name"; Rec.Name)
{
ApplicationArea = All;
Caption = 'Customer Name';
- ToolTip = 'Specifies the customer name.';
+ }
+ field(DisplayValue; DisplayValue)
+ {
+ ApplicationArea = All;
+ Caption = 'Display Value';
+ ToolTip = 'Specifies temporary text for this page; the text is not saved in the customer record.';
}
}
}
}
+
+ var
+ DisplayValue: Text[100];
}
diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.md b/microsoft/knowledge/style/caption-required-on-page-fields.md
index e3a69c3..6381c4c 100644
--- a/microsoft/knowledge/style/caption-required-on-page-fields.md
+++ b/microsoft/knowledge/style/caption-required-on-page-fields.md
@@ -1,28 +1,38 @@
---
bc-version: [all]
domain: style
-keywords: [caption, page-field, aa0225, aa0226, codecop, captionclass]
+keywords: [caption, page-field, source-field, inheritance, aa0225, aa0226, codecop, captionclass, false-positive]
technologies: [al]
countries: [w1]
application-area: [all]
---
-# Every page field needs a `Caption` (CodeCop AA0225/AA0226)
+# Page fields can inherit their source table field's `Caption`
## Description
-CodeCop AA0225 and AA0226 require every field control to expose a `Caption` property, separately from the field's source name. The caption is what the user sees as the column header or label; the source name is what the code uses to reference the field. Without an explicit `Caption`, AL falls back to the source field's caption β which may be wrong for the page's context β or to the field name itself in code casing, which surfaces internal naming to users and to translators.
+A page field bound to a table field inherits the source field's `Caption` unless the page overrides it. An inherited caption is valid, user-facing, and translatable; omitting a page-level `Caption` does not mean the control displays an internal identifier or loses translations. CodeCop AA0225/AA0226 concern missing or empty captions, not a requirement to duplicate a caption already supplied by the source table field.
-Acceptable exceptions: a field whose caption is inherited via `CaptionClass = '3,5,' + CurrencyCode` (or another CaptionClass formula) does not need a literal `Caption`; the formula provides it. API pages and test pages may omit captions because their consumers are not human users. Boolean fields whose name already reads as a sentence β `Enabled`, `Posted`, `Released` β do not need a redundant Caption that repeats the name.
+Redundant page-level captions compile successfully, so compiler-error recovery does not prevent an agent from adding them. This guidance prevents that false positive rather than replacing analyzer diagnostics.
+
+Controls bound to variables or expressions cannot rely on table-field caption inheritance. For user-facing fields that need a label, supply a `Caption` or a `CaptionClass` that resolves to the intended caption. API pages are not human-facing UI; do not apply this UI-label guidance to their API contract names.
## Best Practice
-`Caption = 'Customer No.';` paired with `ToolTip = 'Specifies β¦';`. Captions are short, noun-phrase, title-case for primary labels; sentence-case is allowed for descriptive labels that read as a sentence fragment.
+Define the shared caption on the table field and let bound page fields inherit it. Add a page-level `Caption` only when there is no suitable inherited caption or the page genuinely needs different wording. Keep a valid `CaptionClass` rather than adding a redundant literal caption.
-See sample: `caption-required-on-page-fields.good.al`.
+Before reporting a missing caption, inspect the binding and source field, including dependency symbols when needed. If the source definition is unavailable, do not treat an omitted page property as proof that the caption is missing. Caption and tooltip requirements are separate: do not add a `ToolTip` just because a caption is being reviewed; see [tooltip inheritance guidance](tooltip-required-on-page-fields.md).
+
+See sample: [`caption-required-on-page-fields.good.al`](caption-required-on-page-fields.good.al). Caption inheritance applies across BC versions; the sample uses BC24/runtime 13.0 or later to also define tooltips on its table fields.
## Anti Pattern
-A field control with no `Caption` and no `CaptionClass`, or `Caption = '';`. The user sees the internal identifier as the column header and the translation pipeline has nothing to translate.
+A user-facing field that needs a label but has no non-empty explicit or inherited caption and no resolving `CaptionClass` has a genuine labeling gap. This includes `Caption = '';` when no `CaptionClass` supplies the label. A variable name alone is not a translatable caption.
-See sample: `caption-required-on-page-fields.bad.al`.
+The opposite review defect is flagging a bound field solely because it omits a page-level `Caption`, or inserting a copy of the table field's caption to satisfy AA0225/AA0226. That adds redundant text and prevents subsequent table-caption changes from flowing through to the page.
+
+See sample: [`caption-required-on-page-fields.bad.al`](caption-required-on-page-fields.bad.al).
+
+## References
+
+[Caption property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-caption-property) and [ToolTip property remarks documenting inheritance of both properties](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-tooltip-property).
diff --git a/microsoft/knowledge/style/case-action-on-line-after-possibility.md b/microsoft/knowledge/style/case-action-on-line-after-possibility.md
index c928375..73dd132 100644
--- a/microsoft/knowledge/style/case-action-on-line-after-possibility.md
+++ b/microsoft/knowledge/style/case-action-on-line-after-possibility.md
@@ -17,10 +17,10 @@ In an AL `case` statement, the action for each label is written on the line that
Each case label sits on its own line, terminated by `:`. The action below it is indented; multi-statement actions open with `begin` on the label line and close with `end;` on its own line.
-See sample: `case-action-on-line-after-possibility.good.al`.
+See sample: [`case-action-on-line-after-possibility.good.al`](case-action-on-line-after-possibility.good.al).
## Anti Pattern
`'A': Letter2 := '10';` (single-line label and action), and `'C': begin Letter2 := '12'; DoSomething(); end;` (everything on one line including the block body). Both defeat per-line diff review and crowd the control flow.
-See sample: `case-action-on-line-after-possibility.bad.al`.
+See sample: [`case-action-on-line-after-possibility.bad.al`](case-action-on-line-after-possibility.bad.al).
diff --git a/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.bad.al b/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.bad.al
new file mode 100644
index 0000000..c520ee0
--- /dev/null
+++ b/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.bad.al
@@ -0,0 +1,18 @@
+codeunit 50102 "Bad Review Scheduling"
+{
+ procedure NextReviewDate(Interval: DateFormula; ReferenceDate: Date): Date
+ begin
+ if Format(Interval) = '' then
+ Evaluate(Interval, '1W');
+
+ exit(CalcDate(Interval, ReferenceDate));
+ end;
+
+ procedure NextReviewFromUserInput(UserFormulaText: Text; ReferenceDate: Date): Date
+ var
+ Interval: DateFormula;
+ begin
+ Evaluate(Interval, UserFormulaText);
+ exit(NextReviewDate(Interval, ReferenceDate));
+ end;
+}
diff --git a/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.good.al b/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.good.al
new file mode 100644
index 0000000..ee07dbe
--- /dev/null
+++ b/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.good.al
@@ -0,0 +1,18 @@
+codeunit 50101 "Good Review Scheduling"
+{
+ procedure NextReviewDate(Interval: DateFormula; ReferenceDate: Date): Date
+ begin
+ if Format(Interval) = '' then
+ Evaluate(Interval, '<1W>');
+
+ exit(CalcDate(Interval, ReferenceDate));
+ end;
+
+ procedure NextReviewFromUserInput(UserFormulaText: Text; ReferenceDate: Date): Date
+ var
+ Interval: DateFormula;
+ begin
+ Evaluate(Interval, UserFormulaText);
+ exit(NextReviewDate(Interval, ReferenceDate));
+ end;
+}
diff --git a/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.md b/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.md
new file mode 100644
index 0000000..91fcfe5
--- /dev/null
+++ b/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.md
@@ -0,0 +1,38 @@
+---
+bc-version: [all]
+domain: style
+keywords: [dateformula, evaluate, calcdate, date-expression, language-independent, multilanguage, global-language]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Parse DateFormula constants with language-independent input
+
+## Description
+
+A `DateFormula` stores a formula in a language-independent representation, but `Evaluate` must first interpret its text input. Declaring the destination as `DateFormula` does not make an English literal such as `1W` independent of the session language: French uses `S` for weeks. Passing the resulting typed variable to `CalcDate` satisfies that call's CodeCop AA0462 argument requirement, but cannot repair a parsing failure that already happened in `Evaluate`.
+
+## Best Practice
+
+For an application-defined formula in a normal two-argument `Evaluate` call, use the generic units inside angle brackets, such as `<1W>`. Apply this at the text-to-`DateFormula` boundary, including a visible constant passed through a helper. A label's `Locked = true` prevents translation of its text; it does not make unbracketed English units language independent.
+
+Preserve genuinely localized input: text entered by the user, or already formatted for the same session language, should be parsed in that language. Do not blindly wrap that text in angle brackets. Already invariant `<...>` literals, explicit import-format conversions, a typed formula passed to `CalcDate`, and `Format(Interval) = ''` checks are not findings without an unsafe constant at the parsing boundary.
+
+See sample: [`dateformula-evaluate-needs-language-independent-literals.good.al`](dateformula-evaluate-needs-language-independent-literals.good.al).
+
+## Anti Pattern
+
+A hard-coded, language-fixed formula such as `1W` flows into a normal two-argument `Evaluate` whose destination is known to be `DateFormula`, and the application expects that default to work across session languages. Require the destination type and constant provenance; an arbitrary `Evaluate` call or dynamic text parameter is not enough. The resulting code can compile and work in English while failing when the same default is first needed in another language.
+
+Do not report direct `CalcDate` text arguments under this article: CodeCop AA0462 already owns the requirement for a typed formula or angle-bracketed text there. Its typed-argument check does not establish that an earlier `Evaluate` parsed language-independent input.
+
+See sample: [`dateformula-evaluate-needs-language-independent-literals.bad.al`](dateformula-evaluate-needs-language-independent-literals.bad.al).
+
+## References
+
+[DateFormula data type](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/dateformula/dateformula-data-type) and [CalcDate language behavior](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/system/system-calcdate-dateformula-date-method).
+
+[CodeCop AA0462](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/analyzers/codecop-aa0462) defines the separate direct-`CalcDate` check. In a CodeCop compilation probe against BC28.5 symbols, the direct text control produced AA0462; `Evaluate(Interval, '1W')` followed by typed `CalcDate` did not.
+
+[BaseApp retention scheduling](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyScheduler.Codeunit.al#L73-L97) initializes a typed formula with an invariant literal.
diff --git a/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md
index f8ee5bb..0276192 100644
--- a/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md
+++ b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md
@@ -17,10 +17,10 @@ application-area: [all]
Declare a `Label` with the `Err` suffix and the appropriate `Comment` for placeholders, then call `Error(YourErr, arg1, arg2)`. The same rule applies to `Message`, `Confirm`, and other UI primitives: format string in, parameters as separate arguments, no `StrSubstNo` wrapper at the call site, no string concatenation. An `Error('')` (empty message) is acceptable when the calling code expects another layer to emit the actual diagnostic.
-See sample: `error-passes-parameters-directly-not-strsubstno.good.al`.
+See sample: [`error-passes-parameters-directly-not-strsubstno.good.al`](error-passes-parameters-directly-not-strsubstno.good.al).
## Anti Pattern
`Error(StrSubstNo(CustomerNotFoundErr, CustomerNo))` and `Error(CustomerNotFoundErr + ': ' + CustomerNo)` both defeat the translation and analysis machinery. Reviewers should treat `StrSubstNo` appearing as an argument to `Error`, `Message`, `Confirm`, or `StrMenu` as an unconditional signal to rewrite.
-See sample: `error-passes-parameters-directly-not-strsubstno.bad.al`.
+See sample: [`error-passes-parameters-directly-not-strsubstno.bad.al`](error-passes-parameters-directly-not-strsubstno.bad.al).
diff --git a/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md b/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md
deleted file mode 100644
index e408ebb..0000000
--- a/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [event-subscriber, parameter-name, publisher, signature, eventsubscriber, false-positive]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Event subscriber parameter names must match the publisher signature
-
-## Description
-
-In AL, an `[EventSubscriber]` procedure is bound to its publisher by event name and parameter list. For every parameter the subscriber declares, the name is not a style choice β it must match the name the publisher declared. The compiler validates the match at build time and emits an error if the subscriber renames a parameter. This means a reviewer cannot apply a generic "use better names" pass to subscriber parameters: `Sender`, `Rec`, `xRec`, `RunTrigger`, the table-and-field-specific parameter names a publisher emits β all are dictated by the publisher and must be reproduced verbatim.
-
-A subscriber may, however, declare fewer parameters than the publisher. AL binds each subscriber parameter to the publisher parameter of the same name, so the subscriber can omit any parameters its handler does not use, from any position, and can even declare the ones it keeps in a different order than the publisher. This compiles and binds correctly, so a shorter or differently ordered subscriber signature is not a signature mismatch. In shipping BCApps code, `Test Runner - Mgt::OnBeforeTestMethodRun` publishes `CurrentTestMethodLine, CodeunitID, CodeunitName, FunctionName, FunctionTestPermissions, Skip`, and subscribers such as `ALTestRunnerResetEnvironment` bind to it while omitting `Skip` and declaring `CurrentTestMethodLine` last.
-
-## Best Practice
-
-Copy each parameter's name and type from the publisher verbatim for every parameter the subscriber keeps, and omit the ones the handler does not use. When in doubt, navigate to the publisher (`OnAfterValidateEvent`, `OnBeforePostSalesDoc`, etc.) and copy its parameter list. Style rules that apply to other locals β descriptive names, no spaces β do not apply to subscriber parameters. Do not flag a subscriber for declaring fewer parameters than the publisher, for omitting one from the middle of the list, or for declaring them in a different order, as long as every parameter it does declare matches a publisher parameter by name and type: that is valid AL, not a mismatch.
-
-## Anti Pattern
-
-Renaming a publisher parameter to look prettier in the subscriber. The build breaks immediately, because the name is what the runtime binds on. More insidiously, a parameter name that happens to match by coincidence in one event publisher but not in a similar one will compile in some versions of BC and fail in others when the publisher signature evolves.
-
-Detection: a subscriber parameter whose name or type does not correspond to any parameter on the publisher β not a subscriber that merely declares fewer parameters, drops one from the middle, or lists them in a different order.
diff --git a/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md
index a74f1aa..6a71f4c 100644
--- a/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md
+++ b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md
@@ -17,10 +17,10 @@ application-area: [all]
Reach for `FieldCaption("Location Code")` and `TableCaption()` whenever the value flows into a UI primitive. The same rule applies to format parameters: `Error(SomeErr, FieldCaption("Status"), TableCaption(), "Status")` rather than `Error(SomeErr, FieldName("Status"), TableName(), "Status")`. The captions follow the user's language; the names do not.
-See sample: `fieldcaption-not-fieldname-in-user-messages.good.al`.
+See sample: [`fieldcaption-not-fieldname-in-user-messages.good.al`](fieldcaption-not-fieldname-in-user-messages.good.al).
## Anti Pattern
`Message('Updated %1', TableName())` or `Confirm(UpdateLocationQst, true, FieldName("Location Code"))`. The user sees the English internal name in every locale, and any future rename of the caption fails to reach the message.
-See sample: `fieldcaption-not-fieldname-in-user-messages.bad.al`.
+See sample: [`fieldcaption-not-fieldname-in-user-messages.bad.al`](fieldcaption-not-fieldname-in-user-messages.bad.al).
diff --git a/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.bad.al b/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.bad.al
new file mode 100644
index 0000000..77facce
--- /dev/null
+++ b/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.bad.al
@@ -0,0 +1,22 @@
+table 50101 "Course"
+{
+ fields
+ {
+ field(1; "No."; Code[20]) { }
+ // 1 = Beginner, 2..3 = Intermediate, 4+ = Advanced
+ field(10; Difficulty; Integer) { }
+ }
+}
+
+codeunit 50100 "Course Level Helper"
+{
+ procedure LevelText(Difficulty: Integer): Text
+ begin
+ case Difficulty of
+ 1:
+ exit('Beginner');
+ 2, 3:
+ exit('Intermediate');
+ end;
+ end;
+}
diff --git a/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.good.al b/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.good.al
new file mode 100644
index 0000000..ef0175b
--- /dev/null
+++ b/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.good.al
@@ -0,0 +1,17 @@
+enum 50100 "Course Difficulty"
+{
+ Extensible = true;
+
+ value(0; Beginner) { }
+ value(1; Intermediate) { }
+ value(2; Advanced) { }
+}
+
+table 50101 "Course"
+{
+ fields
+ {
+ field(1; "No."; Code[20]) { }
+ field(10; Difficulty; Enum "Course Difficulty") { }
+ }
+}
diff --git a/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.md b/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.md
new file mode 100644
index 0000000..112b977
--- /dev/null
+++ b/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: style
+keywords: [enum, option, integer, magic-number, variable-typing, field-typing]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# A fixed set of named choices must use Enum, not a raw Integer
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+
+When a variable or field represents a fixed set of named, mutually exclusive states β a difficulty level, a document type, a processing status β it should be typed as `Enum` (or `Option` when extending an object that still uses the legacy type). Representing that same state as a plain `Integer` and tracking the meaning of each value in a comment or in a developer's head is a magic-number anti-pattern: the compiler cannot catch an out-of-range value, and branches read as opaque numbers instead of names. This is about semantics, not member count, matching `binary-choice-must-be-boolean.md`'s own distinction: a domain concept that is genuinely a stable true/false predicate belongs in `Boolean` even if someone represents it as a two-value `Enum`, while a domain concept with exactly two current named states is not automatically a Boolean in disguise β it stays an `Enum` when the states are named alternatives rather than a yes/no flag, or when it needs to implement an interface, preserve an existing contract, or leave room for a future third value.
+
+## Best Practice
+
+Declare an `Enum` with named values and branch on the enum value, not a raw number.
+
+See sample: [`fixed-choice-set-must-use-enum-not-integer.good.al`](fixed-choice-set-must-use-enum-not-integer.good.al).
+
+## Anti Pattern
+
+Using a plain `Integer` field with the meaning of each value tracked only in a comment pushes the documentation of the states into something the compiler cannot check and a future maintainer cannot rely on.
+
+See sample: [`fixed-choice-set-must-use-enum-not-integer.bad.al`](fixed-choice-set-must-use-enum-not-integer.bad.al).
diff --git a/microsoft/knowledge/style/function-call-parentheses-required.bad.al b/microsoft/knowledge/style/function-call-parentheses-required.bad.al
deleted file mode 100644
index 2677716..0000000
--- a/microsoft/knowledge/style/function-call-parentheses-required.bad.al
+++ /dev/null
@@ -1,11 +0,0 @@
-codeunit 50213 "Sample Parens Bad"
-{
- procedure Run()
- var
- Customer: Record Customer;
- begin
- Customer.Init;
- if Customer.FindFirst then
- Customer.Modify;
- end;
-}
diff --git a/microsoft/knowledge/style/function-call-parentheses-required.good.al b/microsoft/knowledge/style/function-call-parentheses-required.good.al
deleted file mode 100644
index 53f6f85..0000000
--- a/microsoft/knowledge/style/function-call-parentheses-required.good.al
+++ /dev/null
@@ -1,11 +0,0 @@
-codeunit 50212 "Sample Parens Good"
-{
- procedure Run()
- var
- Customer: Record Customer;
- begin
- Customer.Init();
- if Customer.FindFirst() then
- Customer.Modify();
- end;
-}
diff --git a/microsoft/knowledge/style/function-call-parentheses-required.md b/microsoft/knowledge/style/function-call-parentheses-required.md
deleted file mode 100644
index 31fbaf8..0000000
--- a/microsoft/knowledge/style/function-call-parentheses-required.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [parentheses, function-call, method-call, aa0008, codecop]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Always write parentheses on procedure calls (CodeCop AA0008)
-
-## Description
-
-AL allows a parameterless procedure to be called without parentheses β `Customer.Init` instead of `Customer.Init()` β and the result is syntactically identical at runtime. CodeCop AA0008 still flags the parenthesis-less form. The reason is twofold: written without parentheses, a procedure call is visually indistinguishable from a property read, which makes BC code harder to scan; and the same identifier may exist as both a property and a procedure on different objects, so the parentheses are the only local signal that this is a call. The rule applies to every parameterless invocation, including `Init`, `Insert`, `Modify`, `Delete`, `DeleteAll`, `FindFirst`, `FindSet`, `Next`, `Get`, `CalcFields`, and user-defined procedures.
-
-## Best Practice
-
-Always write `()` on a procedure call, even when it takes no arguments: `Customer.Init();`, `TempBuffer.DeleteAll();`, `if Customer.FindFirst() then β¦`. The same applies inside expressions and as a condition.
-
-See sample: `function-call-parentheses-required.good.al`.
-
-## Anti Pattern
-
-`Customer.Init;`, `TempBuffer.DeleteAll;`, `if Customer.FindFirst then β¦`. Every one of those is an AA0008 violation. Reviewers should treat a parameterless procedure name appearing without parentheses as a defect, even though the compiler accepts it.
-
-See sample: `function-call-parentheses-required.bad.al`.
diff --git a/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.bad.al b/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.bad.al
new file mode 100644
index 0000000..755991b
--- /dev/null
+++ b/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.bad.al
@@ -0,0 +1,12 @@
+codeunit 50100 "Sales Task Notify"
+{
+ procedure NotifyUpdate(Count: Integer; CustomerNo: Code[20])
+ begin
+ MESSAGE('%1 records updated.', Count);
+
+ IF NOT CONFIRM('Delete %1?', FALSE, CustomerNo) THEN
+ EXIT;
+
+ ERROR(STRSUBSTNO('%1 must not be blank.', CustomerNo));
+ end;
+}
diff --git a/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.good.al b/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.good.al
new file mode 100644
index 0000000..a8551b4
--- /dev/null
+++ b/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.good.al
@@ -0,0 +1,12 @@
+codeunit 50100 "Sales Task Notify"
+{
+ procedure NotifyUpdate(Count: Integer; CustomerNo: Code[20])
+ begin
+ Message('%1 records updated.', Count);
+
+ if not Confirm('Delete %1?', false, CustomerNo) then
+ exit;
+
+ Error(StrSubstNo('%1 must not be blank.', CustomerNo));
+ end;
+}
diff --git a/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.md b/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.md
new file mode 100644
index 0000000..761650b
--- /dev/null
+++ b/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: style
+keywords: [casing, intrinsic-function, built-in-function, message, error, confirm, strsubstno, legacy, c-al, pascalcase]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Intrinsic AL function calls use modern casing, not legacy ALL-CAPS
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+
+AL is case-insensitive, so `MESSAGE(...)`, `ERROR(...)`, `CONFIRM(...)`, and `STRSUBSTNO(...)` compile and run identically to `Message(...)`, `Error(...)`, `Confirm(...)`, and `StrSubstNo(...)`. Modern AL β Microsoft's own current samples and current reference codebases β writes intrinsic/built-in function calls in the casing Microsoft assigns to the function's declared name, typically PascalCase. This is a codebase-convention claim, not a claim about what the VS Code formatter enforces: the formatter normalizes particular syntax but is not a mechanism for recasing every intrinsic function call, so do not cite formatter behavior as the reason to follow this convention. ALL-CAPS calls are a holdover from classic C/AL and signal code that has not been modernized, even though it compiles and runs correctly. Reserved keywords such as `if`, `begin`, and `for` are a separate, already-tooled concern; intrinsic function names are identifiers, not keywords, so that tooling does not catch ALL-CAPS intrinsic function calls.
+
+## Best Practice
+
+Call intrinsic functions in their modern, PascalCase form.
+
+See sample: [`intrinsic-al-functions-must-use-modern-casing.good.al`](intrinsic-al-functions-must-use-modern-casing.good.al).
+
+## Anti Pattern
+
+ALL-CAPS intrinsic function calls trip no compiler error, but they are a reliable signal that a code block was copied from old C/AL material or outdated training content rather than written against current AL conventions.
+
+See sample: [`intrinsic-al-functions-must-use-modern-casing.bad.al`](intrinsic-al-functions-must-use-modern-casing.bad.al).
diff --git a/microsoft/knowledge/style/label-comment-explains-placeholders.md b/microsoft/knowledge/style/label-comment-explains-placeholders.md
index 43d9c1f..63be79e 100644
--- a/microsoft/knowledge/style/label-comment-explains-placeholders.md
+++ b/microsoft/knowledge/style/label-comment-explains-placeholders.md
@@ -17,10 +17,10 @@ application-area: [all]
Write the Comment in the form `'%1 = Customer No., %2 = Sales Header No.'` β one entry per placeholder, matched by ordinal, named in the vocabulary of the BC domain. When the label is reused across multiple call sites, the Comment names the canonical meaning all call sites must conform to.
-See sample: `label-comment-explains-placeholders.good.al`.
+See sample: [`label-comment-explains-placeholders.good.al`](label-comment-explains-placeholders.good.al).
## Anti Pattern
A label with two or more placeholders and no Comment, leaving the translator to guess. Equally bad is a Comment that only restates the placeholders (`'%1 and %2 are values'`) without naming what they are. Both fail in translation: the localized string ends up grammatically or semantically wrong, and the bug surfaces only in a non-English tenant.
-See sample: `label-comment-explains-placeholders.bad.al`.
+See sample: [`label-comment-explains-placeholders.bad.al`](label-comment-explains-placeholders.bad.al).
diff --git a/microsoft/knowledge/style/label-locked-for-non-translatable.md b/microsoft/knowledge/style/label-locked-for-non-translatable.md
index 77f054b..888d22b 100644
--- a/microsoft/knowledge/style/label-locked-for-non-translatable.md
+++ b/microsoft/knowledge/style/label-locked-for-non-translatable.md
@@ -17,10 +17,10 @@ A `Label` is by default surfaced to translators and rewritten per locale. That i
Pair `Locked = true` with the `Tok` suffix for short tokens (`GetMethodTok: Label 'GET', Locked = true;`) and with the `Txt` suffix for telemetry strings that contain format placeholders but should not be localized. The `Locked` parameter and the `Tok` / `Txt` suffix together make the intent unambiguous.
-See sample: `label-locked-for-non-translatable.good.al`.
+See sample: [`label-locked-for-non-translatable.good.al`](label-locked-for-non-translatable.good.al).
## Anti Pattern
`HttpsUrl: Label 'https://example.com';` or `ContentTypeTok: Label 'application/json';` declared without `Locked = true`. The translator localizes them, the integration fails in production for the affected tenant, and the failure is invisible in the developer's English-locale tests.
-See sample: `label-locked-for-non-translatable.bad.al`.
+See sample: [`label-locked-for-non-translatable.bad.al`](label-locked-for-non-translatable.bad.al).
diff --git a/microsoft/knowledge/style/label-suffix-approved-list.bad.al b/microsoft/knowledge/style/label-suffix-approved-list.bad.al
deleted file mode 100644
index 6b227de..0000000
--- a/microsoft/knowledge/style/label-suffix-approved-list.bad.al
+++ /dev/null
@@ -1,14 +0,0 @@
-codeunit 50201 "Sample Label Suffix Bad"
-{
- var
- CannotDeleteLine: Label 'Cannot delete this line.';
- Text000: Label 'Update complete';
- UpdateLocation: Label 'Update location?';
- WrongSuffixTok: Label 'Customer %1 not found.';
-
- procedure ShowMessages()
- begin
- Error(WrongSuffixTok, '10000');
- Message(Text000);
- end;
-}
diff --git a/microsoft/knowledge/style/label-suffix-approved-list.good.al b/microsoft/knowledge/style/label-suffix-approved-list.good.al
deleted file mode 100644
index f3ec561..0000000
--- a/microsoft/knowledge/style/label-suffix-approved-list.good.al
+++ /dev/null
@@ -1,15 +0,0 @@
-codeunit 50200 "Sample Label Suffix Good"
-{
- var
- UpdateCompleteMsg: Label 'Update complete.';
- CustomerNotFoundErr: Label 'Customer %1 does not exist.';
- DeleteRecordQst: Label 'Delete this record?';
- CustomerNameLbl: Label 'Customer Name';
- GetMethodTok: Label 'GET', Locked = true;
- TelemetryStartedTxt: Label 'Operation started for customer %1.', Locked = true;
-
- procedure ShowMessage()
- begin
- Message(UpdateCompleteMsg);
- end;
-}
diff --git a/microsoft/knowledge/style/label-suffix-approved-list.md b/microsoft/knowledge/style/label-suffix-approved-list.md
deleted file mode 100644
index 8e937f3..0000000
--- a/microsoft/knowledge/style/label-suffix-approved-list.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [label, textconst, suffix, aa0074, codecop, msg, err, qst, lbl, tok]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Use approved suffixes on Label and TextConst names (CodeCop AA0074)
-
-## Description
-
-CodeCop AA0074 flags `Label` and `TextConst` identifiers that do not end with an approved usage suffix. The suffix signals at the call site how the text is consumed and what translation behaviour it should get. The approved suffixes and their intended usage are: `Msg` for text shown via `Message()`; `Err` for text passed to `Error()`; `Qst` for text used with `Confirm` or `StrMenu`; `Lbl` for captions and tooltips; `Tok` for short tokens such as `'GET'`, `'PUT'`, `'HTTPS'`, GUIDs, or JSON/XML snippets that are not translated (typically with `Locked = true`); and `Txt` for general text including telemetry messages. A `Label` named `Text000` or `CannotDeleteLine` without a suffix violates the rule, regardless of how readable the prose is.
-
-## Best Practice
-
-Pick the suffix that matches the call where the label is consumed: `UpdateCompleteMsg` for `Message(...)`, `CustomerNotFoundErr` for `Error(...)`, `DeleteRecordQst` for `Confirm(...)`, `CustomerNameLbl` for tooltips and captions, `GetMethodTok` for locked tokens, `TelemetryDataTxt` for telemetry payloads. Suffix choices between `Tok`, `Lbl`, `Txt`, and `Msg` are judgment calls when the suffix is valid for the usage β what matters is that the suffix is on the approved list and matches the actual call.
-
-See sample: `label-suffix-approved-list.good.al`.
-
-## Anti Pattern
-
-A `Label` declared with no suffix (`CannotDeleteLine: Label 'β¦';`), a generic name (`Text000: Label 'β¦';`), or a suffix that contradicts the usage (`WrongSuffixTok: Label 'Customer %1 not found.'` then passed to `Error()`). All three trip AA0074 or its reviewers and obscure the call-site contract.
-
-See sample: `label-suffix-approved-list.bad.al`.
diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al b/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al
deleted file mode 100644
index 83d5994..0000000
--- a/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al
+++ /dev/null
@@ -1,14 +0,0 @@
-codeunit 50245 "Sample Upper Keywords Bad"
-{
- procedure Walk(VAR Customer: Record Customer)
- VAR
- Found: Boolean;
- BEGIN
- IF Customer.FindSet() THEN
- REPEAT
- Found := TRUE;
- UNTIL Customer.Next() = 0;
- IF Found THEN
- EXIT;
- END;
-}
diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.good.al b/microsoft/knowledge/style/lowercase-reserved-keywords.good.al
deleted file mode 100644
index 25fb20e..0000000
--- a/microsoft/knowledge/style/lowercase-reserved-keywords.good.al
+++ /dev/null
@@ -1,14 +0,0 @@
-codeunit 50244 "Sample Lower Keywords Good"
-{
- procedure Walk(var Customer: Record Customer)
- var
- Found: Boolean;
- begin
- if Customer.FindSet() then
- repeat
- Found := true;
- until Customer.Next() = 0;
- if Found then
- exit;
- end;
-}
diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.md b/microsoft/knowledge/style/lowercase-reserved-keywords.md
deleted file mode 100644
index f14b973..0000000
--- a/microsoft/knowledge/style/lowercase-reserved-keywords.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [reserved-keyword, lowercase, aa0241, codecop, if, then, begin]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Reserved keywords are written in lowercase (CodeCop AA0241)
-
-## Description
-
-CodeCop AA0241 requires reserved AL keywords β `if`, `then`, `else`, `begin`, `end`, `var`, `procedure`, `local`, `internal`, `for`, `while`, `repeat`, `until`, `case`, `of`, `do`, `not`, `and`, `or`, `exit`, `break`, `skip`, `quit`, and the rest β to be lowercase. Old Navision and C/AL code used `IFβ¦THENβ¦BEGINβ¦END` in uppercase, and that style still lingers in training data and legacy modules. New AL code is lowercase. The rule applies to keywords only β type names (`Record`, `Codeunit`, `Integer`), property names (`Caption`, `ToolTip`), and identifiers are unaffected.
-
-Test codeunits that retain legacy uppercase forms (`OPENEDIT`, `ASSERTERROR`, `VALUE`) are an accepted exception: the test framework historically uses those identifiers and rewriting them brings no benefit. The rule applies to new code in modified lines, not to long-standing test patterns.
-
-## Best Practice
-
-Write keywords lowercase: `if Condition then begin β¦ end;`, `repeat β¦ until Found;`, `for i := 1 to N do β¦`. The standard AL formatter normalizes casing automatically.
-
-See sample: `lowercase-reserved-keywords.good.al`.
-
-## Anti Pattern
-
-`IF Condition THEN BEGIN DoSomething(); END;`, `REPEAT GetNext(); UNTIL Found;`. Uppercase keywords trip AA0241 and signal C/AL-era code that has not been modernized.
-
-See sample: `lowercase-reserved-keywords.bad.al`.
diff --git a/microsoft/knowledge/style/named-invocations-not-object-ids.md b/microsoft/knowledge/style/named-invocations-not-object-ids.md
index 413dfc2..be30a5f 100644
--- a/microsoft/knowledge/style/named-invocations-not-object-ids.md
+++ b/microsoft/knowledge/style/named-invocations-not-object-ids.md
@@ -17,10 +17,10 @@ application-area: [all]
When invoking an object whose named alias is available in the same app (or in a dependency the current app already references), use the named form: `Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine)`, `Report.Run(Report::"Sales - Invoice", true)`. The same applies to `Codeunit.Run`, `XmlPort.Run`, `Query.Open`, and any platform method that takes an object reference. The named form makes diffs reviewable β a rename is visible β and makes log output and stack traces interpretable.
-See sample: `named-invocations-not-object-ids.good.al`.
+See sample: [`named-invocations-not-object-ids.good.al`](named-invocations-not-object-ids.good.al).
## Anti Pattern
`Page.RunModal(525, β¦)` or `Report.Run(206, true)`. The numeric form is unreadable, fragile across renumbering, and breaks every search that looks for callers of a named object.
-See sample: `named-invocations-not-object-ids.bad.al`.
+See sample: [`named-invocations-not-object-ids.bad.al`](named-invocations-not-object-ids.bad.al).
diff --git a/microsoft/knowledge/style/namespace-must-be-verified-from-source.bad.al b/microsoft/knowledge/style/namespace-must-be-verified-from-source.bad.al
new file mode 100644
index 0000000..0cd54b7
--- /dev/null
+++ b/microsoft/knowledge/style/namespace-must-be-verified-from-source.bad.al
@@ -0,0 +1,13 @@
+namespace Contoso.Extensions;
+
+using System.Performance; // guessed; never verified against the source file
+
+codeunit 50100 "Tooling Extension"
+{
+ procedure Run()
+ var
+ ToolingPage: Page "Some Tooling Page"; // guessed namespace; can still resolve against a stale or cached symbol package, then fail once checked against the object's current source or a freshly downloaded one
+ begin
+ ToolingPage.Run();
+ end;
+}
diff --git a/microsoft/knowledge/style/namespace-must-be-verified-from-source.good.al b/microsoft/knowledge/style/namespace-must-be-verified-from-source.good.al
new file mode 100644
index 0000000..2a8e34a
--- /dev/null
+++ b/microsoft/knowledge/style/namespace-must-be-verified-from-source.good.al
@@ -0,0 +1,16 @@
+// Verified by reading line 1 of the source file for "Some Tooling Page":
+// namespace System.Tooling;
+
+namespace Contoso.Extensions;
+
+using System.Tooling;
+
+codeunit 50100 "Tooling Extension"
+{
+ procedure Run()
+ var
+ ToolingPage: Page "Some Tooling Page";
+ begin
+ ToolingPage.Run();
+ end;
+}
diff --git a/microsoft/knowledge/style/namespace-must-be-verified-from-source.md b/microsoft/knowledge/style/namespace-must-be-verified-from-source.md
new file mode 100644
index 0000000..52e8bbc
--- /dev/null
+++ b/microsoft/knowledge/style/namespace-must-be-verified-from-source.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: style
+keywords: [namespace, verification, source-of-truth, using-statement]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Resolve a namespace from the referenced object's source or symbols, never by guessing
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+
+Since Business Central 2024 release wave 1, Microsoft's own objects are organized under a deep `Microsoft.*` namespace tree that has been renamed and restructured repeatedly. When adding a `using` directive for an existing AL object (table, codeunit, page, enum, interface, etc.), guessing its namespace from the object's name, from an older codebase, or from general familiarity produces a statement that can look plausible and still resolve to the wrong object, or fail to resolve at all, once checked against the object's actual current namespace. The reliable sources are the object's own source file (its `namespace` declaration) or, for a dependency without accessible source, its AL symbol package β not the object's name or a remembered convention.
+
+## Best Practice
+
+When referencing an existing AL object, resolve its namespace from that object's actual source file or symbol definition β never infer or invent one from its name, functional area, or naming convention.
+
+See sample: [`namespace-must-be-verified-from-source.good.al`](namespace-must-be-verified-from-source.good.al).
+
+## Anti Pattern
+
+Writing a `using` statement from memory, from an incomplete path, or from a plausible-looking guess. It can appear correct while actually resolving to the wrong object, or fail to resolve, once checked against stale or mismatched symbols, a different build configuration, or the object's actual current source β not because the compiler and the AL Language Server apply different namespace-resolution rules; they don't.
+
+See sample: [`namespace-must-be-verified-from-source.bad.al`](namespace-must-be-verified-from-source.bad.al).
diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al b/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al
deleted file mode 100644
index 1803e1c..0000000
--- a/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al
+++ /dev/null
@@ -1,11 +0,0 @@
-codeunit 50237 "Sample Single Stmt Bad"
-{
- procedure Validate(IsAssemblyOutputLine: Boolean)
- var
- SalesLine: Record "Sales Line";
- begin
- if IsAssemblyOutputLine then begin
- SalesLine.TestField("Order Line No.", 0);
- end;
- end;
-}
diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al b/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al
deleted file mode 100644
index 684a86c..0000000
--- a/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al
+++ /dev/null
@@ -1,10 +0,0 @@
-codeunit 50236 "Sample Single Stmt Good"
-{
- procedure Validate(IsAssemblyOutputLine: Boolean)
- var
- SalesLine: Record "Sales Line";
- begin
- if IsAssemblyOutputLine then
- SalesLine.TestField("Order Line No.", 0);
- end;
-}
diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.md b/microsoft/knowledge/style/no-begin-end-around-single-statement.md
deleted file mode 100644
index d7665f7..0000000
--- a/microsoft/knowledge/style/no-begin-end-around-single-statement.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [begin, end, single-statement, aa0013, codecop, compound]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Do not wrap a single statement in `begin β¦ end` (CodeCop AA0013)
-
-## Description
-
-CodeCop AA0013 flags `begin β¦ end` blocks that contain exactly one statement. The compound-block syntax exists to group multiple statements as a unit; using it for a single statement adds two lines and a level of nesting without adding meaning. `if IsAssemblyOutputLine then begin TestField("Order Line No.", 0); end;` should be `if IsAssemblyOutputLine then TestField("Order Line No.", 0);` β one statement, no block. The same logic applies after `else`, `for`, `while`, and `repeat`.
-
-## Best Practice
-
-A single statement following `then`, `else`, `do`, or a case label is written on its own line, indented one level, with no `begin β¦ end`. Use `begin β¦ end` only when there are two or more statements to group.
-
-See sample: `no-begin-end-around-single-statement.good.al`.
-
-## Anti Pattern
-
-`if Cond then begin OneCall(); end;` β single statement wrapped in a block. AA0013 flags it. The reviewer signal is "a `begin` followed by exactly one statement before its `end`."
-
-See sample: `no-begin-end-around-single-statement.bad.al`.
diff --git a/microsoft/knowledge/style/no-else-after-terminating-statement.md b/microsoft/knowledge/style/no-else-after-terminating-statement.md
index 76d7ede..3604dd3 100644
--- a/microsoft/knowledge/style/no-else-after-terminating-statement.md
+++ b/microsoft/knowledge/style/no-else-after-terminating-statement.md
@@ -17,10 +17,10 @@ When the `then` branch of an `if` ends in a terminating statement β `exit`, `b
Drop the `else` when the `then` branch unconditionally exits the procedure or the enclosing loop. The body that would have been inside `else` becomes the unindented continuation.
-See sample: `no-else-after-terminating-statement.good.al`.
+See sample: [`no-else-after-terminating-statement.good.al`](no-else-after-terminating-statement.good.al).
## Anti Pattern
An `if β¦ then Error(β¦) else Error(β¦)` pair where both branches terminate. The `else` is structural noise β the reader cannot tell at a glance whether it exists to handle an actual continuation or simply mirrors the `then`. The fix is to drop `else` and let the second `Error` fall through naturally.
-See sample: `no-else-after-terminating-statement.bad.al`.
+See sample: [`no-else-after-terminating-statement.bad.al`](no-else-after-terminating-statement.bad.al).
diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al b/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al
deleted file mode 100644
index b2f8295..0000000
--- a/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al
+++ /dev/null
@@ -1,11 +0,0 @@
-codeunit 50231 "Sample No Space Paren Bad"
-{
- procedure Lookup(CustomerNo: Code[20])
- var
- Customer: Record Customer;
- GreetingMsg: Label 'Hello %1';
- begin
- if Customer.Get ( CustomerNo ) then
- Message ( GreetingMsg, Customer.Name );
- end;
-}
diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al b/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al
deleted file mode 100644
index eb16dc3..0000000
--- a/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al
+++ /dev/null
@@ -1,11 +0,0 @@
-codeunit 50230 "Sample No Space Paren Good"
-{
- procedure Lookup(CustomerNo: Code[20])
- var
- Customer: Record Customer;
- GreetingMsg: Label 'Hello %1';
- begin
- if Customer.Get(CustomerNo) then
- Message(GreetingMsg, Customer.Name);
- end;
-}
diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.md b/microsoft/knowledge/style/no-space-before-method-parenthesis.md
deleted file mode 100644
index d7a2f69..0000000
--- a/microsoft/knowledge/style/no-space-before-method-parenthesis.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [spacing, parenthesis, method-call, aa0002, codecop]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# No space between a method name and its opening parenthesis (CodeCop AA0002)
-
-## Description
-
-CodeCop AA0002 forbids whitespace between a procedure/method name and its `(`. `Customer.Get(CustomerNo)` is correct; `Customer.Get (CustomerNo)` is not. The rule applies to user-defined procedures, system methods (`Insert`, `FindFirst`, `CalcFields`), trigger-style invocations, and the parenthesised cast/conversion forms (`Format(Value)`, `CopyStr(Source, 1, 10)`). The whitespace between `(` and the first argument, and between the last argument and `)`, is also forbidden by the same rule.
-
-## Best Practice
-
-`Customer.Get(CustomerNo)`, `Customer.SetFilter("No.", '%1', '*A*')`, `Message(GreetingMsg, UserName)`. The standard AL formatter enforces this automatically.
-
-See sample: `no-space-before-method-parenthesis.good.al`.
-
-## Anti Pattern
-
-`Customer.Get ( CustomerNo )`, `Message ( GreetingMsg, UserName )`. Both trip AA0002 and read as if the call had an extra unnamed parameter β a small but persistent friction every reader pays.
-
-See sample: `no-space-before-method-parenthesis.bad.al`.
diff --git a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al
deleted file mode 100644
index 9d5269c..0000000
--- a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al
+++ /dev/null
@@ -1,17 +0,0 @@
-table 50255 "Sample OptionCaption Bad"
-{
- fields
- {
- field(1; Status; Option)
- {
- Caption = 'Status';
- OptionMembers = Open,Released,Pending;
- }
- field(2; Priority; Option)
- {
- Caption = 'Priority';
- OptionMembers = Low,Medium,High,Critical;
- OptionCaption = 'Low,Medium,High';
- }
- }
-}
diff --git a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al
deleted file mode 100644
index d0e9866..0000000
--- a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al
+++ /dev/null
@@ -1,18 +0,0 @@
-table 50254 "Sample OptionCaption Good"
-{
- fields
- {
- field(1; Status; Option)
- {
- Caption = 'Status';
- OptionMembers = Open,Released,Pending;
- OptionCaption = 'Open,Released,Pending';
- }
- field(2; Priority; Option)
- {
- Caption = 'Priority';
- OptionMembers = Low,Medium,High,Critical;
- OptionCaption = 'Low,Medium,High,Critical';
- }
- }
-}
diff --git a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md
deleted file mode 100644
index d961040..0000000
--- a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [optioncaption, option, member-count, aa0221, aa0223, aa0224]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Option fields need `OptionCaption`, and its element count must match `OptionMembers` (CodeCop AA0221/AA0223/AA0224)
-
-## Description
-
-CodeCop AA0221 requires an `OptionCaption` on every option-type field that is not sourced from a table column (table-sourced option fields inherit the captions of the underlying field). AA0223 and AA0224 add two integrity checks: the number of comma-separated entries in `OptionCaption` must equal the number of entries in `OptionMembers`, and each caption must align by position with its member. The position alignment is what the platform uses to translate option values β the `OptionMembers` list never changes per locale, the `OptionCaption` list does. A mismatch in count or order produces silent corruption: the option `Released` shows the caption that belongs to `Pending`, and the bug is locale-dependent.
-
-## Best Practice
-
-`OptionMembers = Open,Released,Pending;` and `OptionCaption = 'Open,Released,Pending';` β same count, same order. When adding a new member, update both lines in the same commit.
-
-See sample: `optioncaption-required-and-matches-membercount.good.al`.
-
-## Anti Pattern
-
-`OptionMembers = Open,Released,Pending;` with no `OptionCaption` at all (the user sees the raw English members and translation is impossible), or `OptionMembers = Low,Medium,High,Critical;` paired with `OptionCaption = 'Low,Medium,High';` β count mismatch, `Critical` displays as blank or carries the wrong caption depending on platform version.
-
-See sample: `optioncaption-required-and-matches-membercount.bad.al`.
diff --git a/microsoft/knowledge/style/pages-must-not-contain-business-logic.bad.al b/microsoft/knowledge/style/pages-must-not-contain-business-logic.bad.al
new file mode 100644
index 0000000..352c515
--- /dev/null
+++ b/microsoft/knowledge/style/pages-must-not-contain-business-logic.bad.al
@@ -0,0 +1,49 @@
+table 50101 "Sample Order Line"
+{
+ fields
+ {
+ field(1; "Document No."; Code[20]) { }
+ field(2; "Line No."; Integer) { }
+ field(10; Quantity; Decimal) { }
+ field(11; "Unit Price"; Decimal) { }
+ field(12; "Line Amount"; Decimal) { }
+ }
+ keys
+ {
+ key(PK; "Document No.", "Line No.") { Clustered = true; }
+ }
+}
+
+page 50100 "Sample Order Line Card"
+{
+ PageType = Card;
+ SourceTable = "Sample Order Line";
+
+ layout
+ {
+ area(content)
+ {
+ repeater(General)
+ {
+ field(quantity; Rec.Quantity) { }
+ field(unitPrice; Rec."Unit Price") { }
+ field(lineAmount; Rec."Line Amount") { }
+ }
+ }
+ }
+
+ actions
+ {
+ area(Processing)
+ {
+ action(Recalculate)
+ {
+ trigger OnAction()
+ begin
+ Rec."Line Amount" := Rec.Quantity * Rec."Unit Price";
+ Rec.Modify();
+ end;
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/style/pages-must-not-contain-business-logic.good.al b/microsoft/knowledge/style/pages-must-not-contain-business-logic.good.al
new file mode 100644
index 0000000..a22f307
--- /dev/null
+++ b/microsoft/knowledge/style/pages-must-not-contain-business-logic.good.al
@@ -0,0 +1,60 @@
+table 50101 "Sample Order Line"
+{
+ fields
+ {
+ field(1; "Document No."; Code[20]) { }
+ field(2; "Line No."; Integer) { }
+ field(10; Quantity; Decimal) { }
+ field(11; "Unit Price"; Decimal) { }
+ field(12; "Line Amount"; Decimal) { }
+ }
+ keys
+ {
+ key(PK; "Document No.", "Line No.") { Clustered = true; }
+ }
+}
+
+codeunit 50100 "Sample Order Line Management"
+{
+ procedure RecalculateLine(var OrderLine: Record "Sample Order Line")
+ begin
+ OrderLine.Validate("Line Amount", OrderLine.Quantity * OrderLine."Unit Price");
+ OrderLine.Modify(true);
+ end;
+}
+
+page 50100 "Sample Order Line Card"
+{
+ PageType = Card;
+ SourceTable = "Sample Order Line";
+
+ layout
+ {
+ area(content)
+ {
+ repeater(General)
+ {
+ field(quantity; Rec.Quantity) { }
+ field(unitPrice; Rec."Unit Price") { }
+ field(lineAmount; Rec."Line Amount") { }
+ }
+ }
+ }
+
+ actions
+ {
+ area(Processing)
+ {
+ action(Recalculate)
+ {
+ trigger OnAction()
+ begin
+ OrderLineMgt.RecalculateLine(Rec);
+ end;
+ }
+ }
+ }
+
+ var
+ OrderLineMgt: Codeunit "Sample Order Line Management";
+}
diff --git a/microsoft/knowledge/style/pages-must-not-contain-business-logic.md b/microsoft/knowledge/style/pages-must-not-contain-business-logic.md
new file mode 100644
index 0000000..ed5a4cb
--- /dev/null
+++ b/microsoft/knowledge/style/pages-must-not-contain-business-logic.md
@@ -0,0 +1,31 @@
+---
+bc-version: [all]
+domain: style
+keywords: [pages, business-logic, codeunit, separation-of-concerns, presentation-layer, rec-modify]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Keep business logic out of page objects
+
+## Description
+
+A page procedure that persists a business mutation directly (`Rec.Modify()` outside the standard record-bound save, or a cross-entry-point business rule implemented only in a page trigger) is an architecture violation even when it compiles: the rule only applies when a user opens that specific page, and silently doesn't run through any other entry point (API, batch job, another page). This is narrower than "no calculation may live on a page" β a presentation-specific calculation (formatting, a derived display value) is fine on the page that shows it, and a reusable data invariant commonly belongs on the table itself (a field's own validation/trigger), not forced into a codeunit merely to keep it off the page. The actual line is entry-point independence: a business operation or invariant that must hold regardless of which entry point touches the record belongs in a codeunit or the table, not solely in one page's trigger.
+
+A narrow set of patterns are conventional rather than violations:
+- A setup page reading and writing its own singleton setup record.
+- A dedicated "Run Conversion" page invoking a conversion codeunit directly.
+- The standard singleton-initialization idiom on `OnOpenPage` (`if not Rec.Get() then begin Rec.Init(); Rec.Insert(); end`) used by cue/activities pages to bootstrap their own presentation-state record β this is not business logic, it is the same pattern used throughout base-app cue pages.
+
+## Best Practice
+
+Delegate all business operations to a codeunit: the page owns presentation, the codeunit owns logic. A calculation or validation triggered from a page action should call a codeunit procedure rather than compute the result inline.
+
+See sample: [`pages-must-not-contain-business-logic.good.al`](pages-must-not-contain-business-logic.good.al).
+
+## Anti Pattern
+
+A cross-entry-point business rule or persisted mutation implemented only in a page trigger β calling `Rec.Modify()` to save a computed business value from `OnValidate`/`OnAction`, or a validation that must hold regardless of caller, instead of routed through a codeunit or the table's own field validation. A presentation-only calculation or a table-owned field invariant is not an instance of this anti-pattern.
+
+See sample: [`pages-must-not-contain-business-logic.bad.al`](pages-must-not-contain-business-logic.bad.al).
diff --git a/microsoft/knowledge/style/single-space-after-not-operator.bad.al b/microsoft/knowledge/style/single-space-after-not-operator.bad.al
deleted file mode 100644
index c7143e7..0000000
--- a/microsoft/knowledge/style/single-space-after-not-operator.bad.al
+++ /dev/null
@@ -1,11 +0,0 @@
-codeunit 50233 "Sample Not Spacing Bad"
-{
- procedure Check(): Boolean
- var
- Customer: Record Customer;
- begin
- if NOT Customer.IsEmpty() then
- exit(true);
- exit(false);
- end;
-}
diff --git a/microsoft/knowledge/style/single-space-after-not-operator.good.al b/microsoft/knowledge/style/single-space-after-not-operator.good.al
deleted file mode 100644
index 92d8f33..0000000
--- a/microsoft/knowledge/style/single-space-after-not-operator.good.al
+++ /dev/null
@@ -1,11 +0,0 @@
-codeunit 50232 "Sample Not Spacing Good"
-{
- procedure Check(): Boolean
- var
- Customer: Record Customer;
- begin
- if not Customer.IsEmpty() then
- exit(true);
- exit(false);
- end;
-}
diff --git a/microsoft/knowledge/style/single-space-after-not-operator.md b/microsoft/knowledge/style/single-space-after-not-operator.md
deleted file mode 100644
index c5d2077..0000000
--- a/microsoft/knowledge/style/single-space-after-not-operator.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [spacing, not, operator, aa0003, codecop]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Exactly one space between `not` and its argument (CodeCop AA0003)
-
-## Description
-
-CodeCop AA0003 requires exactly one space between the `not` operator and the expression it negates. `if not Customer.FindFirst() then β¦` is correct; `if not Customer.FindFirst() then β¦` (two spaces) and `if notCustomer.FindFirst() then β¦` (zero β which fails parsing anyway) are not. The rule is also the place where uppercase `NOT` is flagged in combination with CodeCop AA0241 (reserved keywords must be lowercase): `if NOT Condition then` is doubly wrong.
-
-## Best Practice
-
-`if not Condition then`, `if not Customer.IsEmpty() then`, `exit(not Result)`. One space, lowercase keyword, no parentheses around the bare boolean.
-
-See sample: `single-space-after-not-operator.good.al`.
-
-## Anti Pattern
-
-`if NOT condition then`, `if not condition then`, `if !condition then` (which is not even AL β `!` is not a negation operator in AL). All three either trip AA0003 / AA0241 or fail to compile.
-
-See sample: `single-space-after-not-operator.bad.al`.
diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.bad.al b/microsoft/knowledge/style/single-space-around-binary-operators.bad.al
deleted file mode 100644
index 2515d33..0000000
--- a/microsoft/knowledge/style/single-space-around-binary-operators.bad.al
+++ /dev/null
@@ -1,12 +0,0 @@
-codeunit 50229 "Sample Spaces Op Bad"
-{
- procedure Compute(Amount: Decimal; Quantity: Decimal): Decimal
- var
- Price: Decimal;
- begin
- Price:=Amount*Quantity;
- if (Amount>0)and(Quantity>0) then
- exit(Price);
- exit(0);
- end;
-}
diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.good.al b/microsoft/knowledge/style/single-space-around-binary-operators.good.al
deleted file mode 100644
index 55793d3..0000000
--- a/microsoft/knowledge/style/single-space-around-binary-operators.good.al
+++ /dev/null
@@ -1,12 +0,0 @@
-codeunit 50228 "Sample Spaces Op Good"
-{
- procedure Compute(Amount: Decimal; Quantity: Decimal): Decimal
- var
- Price: Decimal;
- begin
- Price := Amount * Quantity;
- if (Amount > 0) and (Quantity > 0) then
- exit(Price);
- exit(0);
- end;
-}
diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.md b/microsoft/knowledge/style/single-space-around-binary-operators.md
deleted file mode 100644
index a09fef9..0000000
--- a/microsoft/knowledge/style/single-space-around-binary-operators.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [spacing, binary-operator, aa0001, codecop, formatting]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# One space on each side of every binary operator (CodeCop AA0001)
-
-## Description
-
-CodeCop AA0001 requires exactly one space on each side of every binary operator: assignment (`:=`), arithmetic (`+`, `-`, `*`, `/`, `mod`, `div`), comparison (`=`, `<>`, `<`, `<=`, `>`, `>=`), logical (`and`, `or`, `xor`), and string concatenation. `x:=1+2`, `Price:=Amount*Quantity`, `if a=b then`, and `if a and b then` all violate the rule. The rule applies to the binary use of `-` (subtraction); the unary minus (`-Profit`) takes no leading space.
-
-## Best Practice
-
-Write `x := 1 + 2`, `Price := Amount * Quantity`, `if a = b then`, `if a and b then`. The standard AL formatter inserts these spaces automatically; running `Alt+Shift+F` (Format Document) in the AL extension is the simplest way to bring an entire file into compliance.
-
-See sample: `single-space-around-binary-operators.good.al`.
-
-## Anti Pattern
-
-`x:=1+2;`, `Price:=Amount*Quantity;`, `if a=b then`, `if a and b then`. All trip AA0001.
-
-See sample: `single-space-around-binary-operators.bad.al`.
diff --git a/microsoft/knowledge/style/source-organized-by-feature-not-object-type.md b/microsoft/knowledge/style/source-organized-by-feature-not-object-type.md
new file mode 100644
index 0000000..c698093
--- /dev/null
+++ b/microsoft/knowledge/style/source-organized-by-feature-not-object-type.md
@@ -0,0 +1,48 @@
+---
+bc-version: [all]
+domain: style
+keywords: [folder-structure, feature-organization, source-layout, maintainability]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Organize AL source by business feature, not object type
+
+## Description
+
+Folder structure inside an AL app has no effect on compilation or runtime behavior β this is a repository-organization convention, not a platform requirement, and different projects reasonably choose differently. Grouping files by business feature or module (`src/Sales/Invoice/`, `src/NoSeries/`) rather than by AL object type (`src/Tables/`, `src/Pages/`, `src/Codeunits/`) keeps everything belonging to one feature physically together, which many teams find easier to navigate than jumping between object-type folders that share nothing but their AL object kind. Adopt this consistently on a project rather than mixing both schemes, but treat it as a team convention to apply deliberately, not a Microsoft-mandated structure.
+
+Code genuinely shared across multiple features (utility codeunits, common interfaces, shared enums) belongs in a `Common` or `Shared` folder, not duplicated per feature and not left in a catch-all root.
+
+## Best Practice
+
+ src/
+ βββ NoSeries/
+ βββ Sales/
+ β βββ Invoice/
+ β βββ Order/
+ βββ Common/
+
+Each feature folder holds every object type it needs; shared code has one dedicated home.
+
+## Anti Pattern
+
+A repository that documents or has established feature-based organization
+as its convention, but then mixes in object-type folders for new work
+anyway:
+
+ src/
+ βββ Sales/
+ β βββ Invoice/
+ βββ Tables/ <- new objects land here instead of a feature folder
+ βββ Codeunits/
+
+The anti-pattern is inconsistency with the project's own chosen convention,
+not the object-type scheme itself β a repository that deliberately and
+consistently organizes by object type throughout is exercising the other
+reasonable choice described above, not violating this rule. What actually
+costs a reader time is a codebase where some features live under their own
+folder and others are scattered across type folders, so finding everything
+related to one feature means checking both schemes and reassembling it from
+wherever each object happened to land.
diff --git a/microsoft/knowledge/style/temporary-variable-temp-prefix.md b/microsoft/knowledge/style/temporary-variable-temp-prefix.md
index 16e85c2..2df9636 100644
--- a/microsoft/knowledge/style/temporary-variable-temp-prefix.md
+++ b/microsoft/knowledge/style/temporary-variable-temp-prefix.md
@@ -17,10 +17,10 @@ A `Record` variable declared with the `temporary` modifier behaves nothing like
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`.
+See sample: [`temporary-variable-temp-prefix.good.al`](temporary-variable-temp-prefix.good.al).
## Anti Pattern
`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`.
+See sample: [`temporary-variable-temp-prefix.bad.al`](temporary-variable-temp-prefix.bad.al).
diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al b/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al
deleted file mode 100644
index 7fd03a1..0000000
--- a/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al
+++ /dev/null
@@ -1,14 +0,0 @@
-codeunit 50215 "Sample This Bad"
-{
- procedure ProcessRecord(Customer: Record Customer)
- var
- Helper: Codeunit "Sample This Helper";
- begin
- ValidateCustomer(Customer);
- Helper.DoWork();
- end;
-
- local procedure ValidateCustomer(Customer: Record Customer)
- begin
- end;
-}
diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.good.al b/microsoft/knowledge/style/this-keyword-in-codeunits.good.al
deleted file mode 100644
index 392c042..0000000
--- a/microsoft/knowledge/style/this-keyword-in-codeunits.good.al
+++ /dev/null
@@ -1,14 +0,0 @@
-codeunit 50214 "Sample This Good"
-{
- procedure ProcessRecord(Customer: Record Customer)
- var
- Helper: Codeunit "Sample This Helper";
- begin
- this.ValidateCustomer(Customer);
- Helper.DoWork(this);
- end;
-
- local procedure ValidateCustomer(Customer: Record Customer)
- begin
- end;
-}
diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.md b/microsoft/knowledge/style/this-keyword-in-codeunits.md
deleted file mode 100644
index b38cc24..0000000
--- a/microsoft/knowledge/style/this-keyword-in-codeunits.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [25..]
-domain: style
-keywords: [this, codeunit, self-reference, aa0248, scope]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Use the `this` keyword for self-reference inside codeunits (CodeCop AA0248)
-
-## Description
-
-CodeCop AA0248 recommends prefixing self-references inside a codeunit with `this`. `this.ValidateCustomer(Customer)` is unambiguous: the call resolves to a procedure on the current codeunit, not to a local variable or a procedure on a passed-in object. Without the prefix, a reader of a 200-line procedure has to scan the whole codeunit to confirm whether `ValidateCustomer` is local. `this` also makes it possible to pass the current codeunit as an argument β `SomeOtherCodeunit.DoWork(this)` β which is the only way to expose the running codeunit instance to a collaborator. The rule applies only to codeunits, not to pages, reports, queries, or tables β those object types do not have a `this` reference in AL.
-
-## Best Practice
-
-Inside a codeunit, prefix calls to procedures and accesses to global variables on the same codeunit with `this.`, and pass `this` when an external codeunit needs a reference to the running instance.
-
-See sample: `this-keyword-in-codeunits.good.al`.
-
-## Anti Pattern
-
-Calling a codeunit-local procedure as a bare identifier (`ValidateCustomer(Customer)`) when other readings are possible. The ambiguity costs reading time on every encounter and grows with codeunit size.
-
-See sample: `this-keyword-in-codeunits.bad.al`.
diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al b/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al
index e6b356c..6f5f269 100644
--- a/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al
+++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al
@@ -1,23 +1,29 @@
page 50251 "Sample Tooltip Bad"
{
PageType = Card;
- SourceTable = Customer;
layout
{
area(Content)
{
group(General)
{
- field("No."; Rec."No.")
+ Caption = 'General';
+ field(CustomerNoValue; CustomerNoValue)
{
ApplicationArea = All;
+ Caption = 'Customer No.';
}
- field(Amount; Rec."Balance (LCY)")
+ field(PreviewAmount; PreviewAmount)
{
ApplicationArea = All;
+ Caption = 'Preview Amount';
ToolTip = '';
}
}
}
}
+
+ var
+ CustomerNoValue: Code[20];
+ PreviewAmount: Decimal;
}
diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al b/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al
index 1816de5..cd1fc8c 100644
--- a/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al
+++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al
@@ -1,24 +1,62 @@
+// BC24 / runtime 13.0 or later.
+table 50250 "Sample Tooltip Source"
+{
+ Caption = 'Tooltip Source';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20])
+ {
+ Caption = 'No.';
+ ToolTip = 'Specifies the unique number used to distinguish this entry from other entries.';
+ }
+ field(2; Amount; Decimal)
+ {
+ Caption = 'Amount';
+ ToolTip = 'Specifies the monetary value recorded for this entry; changing it updates the saved entry.';
+ }
+ }
+
+ keys
+ {
+ key(PK; "No.")
+ {
+ Clustered = true;
+ }
+ }
+}
+
page 50250 "Sample Tooltip Good"
{
PageType = Card;
- SourceTable = Customer;
+ SourceTable = "Sample Tooltip Source";
layout
{
area(Content)
{
group(General)
{
+ Caption = 'General';
field("No."; Rec."No.")
{
ApplicationArea = All;
- ToolTip = 'Specifies the number that identifies the customer.';
}
- field(Amount; Rec."Balance (LCY)")
+ field(Amount; Rec.Amount)
{
ApplicationArea = All;
- ToolTip = 'Shows the total balance in local currency.';
+ ToolTip = 'Specifies the recorded amount to compare with the temporary preview amount.';
+ }
+ field(PreviewAmount; PreviewAmount)
+ {
+ ApplicationArea = All;
+ Caption = 'Preview Amount';
+ ToolTip = 'Specifies a temporary amount to compare with the recorded entry amount; this value is not saved.';
}
}
}
}
+
+ var
+ PreviewAmount: Decimal;
}
diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.md b/microsoft/knowledge/style/tooltip-required-on-page-fields.md
index a11d4a9..34f74c5 100644
--- a/microsoft/knowledge/style/tooltip-required-on-page-fields.md
+++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.md
@@ -1,30 +1,44 @@
---
bc-version: [all]
domain: style
-keywords: [tooltip, page-field, aa0218, codecop, accessibility, specifies]
+keywords: [tooltip, page-field, source-field, inheritance, aa0218, codecop, accessibility, specifies]
technologies: [al]
countries: [w1]
application-area: [all]
---
-# Every page field needs a `ToolTip` (CodeCop AA0218)
+# Page fields need an explicit or inherited `ToolTip` (CodeCop AA0218)
## Description
-CodeCop AA0218 requires a non-empty `ToolTip` property on every field control on a page. The tooltip is what users see on hover and is what screen readers announce; an empty or missing tooltip removes a piece of UI affordance that is part of BC's accessibility baseline. AppSource technical validation rejects pages with missing tooltips. The companion rules AA0219 and AA0220 push the wording further β tooltips should describe what the field shows, conventionally starting with `'Specifies β¦'`, though `'Shows β¦'` and similar variants are acceptable when they clearly describe the field's purpose.
+User-facing page fields need tooltip text, but it does not have to be declared on each page control. Starting with BC24 (2024 release wave 1), runtime 13.0 supports `ToolTip` on table fields, and bound page fields inherit it unless they override it. A non-empty inherited tooltip satisfies the requirement; do not interpret CodeCop AA0218 as a requirement to repeat it on the page.
-Acceptable exceptions: table fields inside `Upgrade`, `Migration`, `HybridBC14`, `HybridSL`, and `HybridGP` codeunits and tables are allowed to omit the tooltip β those types are not surfaced to users.
+For targets before runtime 13.0, table-field tooltip inheritance is not available, so user-facing page fields need page-level tooltips. Controls bound to variables or expressions also need page-level tooltips because they have no table field to inherit from. This is UI guidance, not a blanket requirement to add tooltips to every table field, including fields never exposed to users.
-AA0218 is a compiler analyzer, but its severity is configured per app in the ruleset and is frequently downgraded to `info`/`None` or disabled entirely. PR review therefore cannot assume the compiler will surface the gap: it is the last line of defence for a missing tooltip and should flag it independently. The one case review must *not* flag is a bound field that inherits a `ToolTip` from its source table field β see `bound-page-field-inherits-source-field-tooltip`.
+AA0218's severity is configured per app and may be downgraded or disabled. Review should still report a genuinely missing tooltip, but absence of a page-level declaration alone is not evidence of a gap. See [bound page-field tooltip inheritance](../ui/bound-page-field-inherits-source-field-tooltip.md).
## Best Practice
-Every field control on a regular page carries `ToolTip = 'Specifies β¦';` (or a clear alternative phrasing). Compose the text in the form "what this value shows" rather than "what the user does with it". In review, raise a `medium`-severity finding for a field that has neither an inline nor an inherited tooltip, independently of whether AA0218 is active in the app's ruleset.
+On runtime 13.0 or later, define shared tooltip text on the table field and omit duplicate page-level properties. Add a page-level `ToolTip` when no tooltip can be inherited or when the page needs different, context-specific help. Describe what the value shows, conventionally starting with "Specifies" or another clear phrasing.
-See sample: `tooltip-required-on-page-fields.good.al`.
+Make the text answer a question the caption does not: what the value is used for, which values or units are expected, or what changing it affects. Do not mechanically generate "Specifies the ." and consider the help complete. Use behavior established by the implementation or requirements; do not invent effects, defaults, or constraints to make a tooltip sound useful. Keep shared table-field help applicable to all pages that inherit it, and improve that shared text rather than duplicating it on each page.
+
+Before raising a `medium`-severity finding, check the target runtime, the control's binding, and the source field's tooltip, including dependency symbols when needed. Report a field with neither an explicit nor an inherited tooltip independently of whether AA0218 is active. If the source definition or target runtime is unavailable, do not assume a missing page property means missing tooltip text.
+
+See sample: [`tooltip-required-on-page-fields.good.al`](tooltip-required-on-page-fields.good.al) (BC24/runtime 13.0 or later).
## Anti Pattern
-A field control with no `ToolTip` property at all, or `ToolTip = '';`. AA0218 flags both; the hover state is blank and the screen reader has nothing to announce.
+A user-facing control with no page-level `ToolTip` and no non-empty source tooltip it can inherit, or a page-level `ToolTip = '';` that leaves the effective tooltip empty.
-See sample: `tooltip-required-on-page-fields.bad.al`.
+Flagging a bound field that already inherits its tooltip, or adding the same tooltip to every page, is also incorrect: duplicate overrides add maintenance and translation work and prevent source-field tooltip changes from reaching those pages.
+
+Treating a non-empty tooltip that merely repeats the caption as useful help is a separate quality issue, not a missing-tooltip finding. Point out the concrete information users need rather than demanding longer wording or a page-level override for its own sake.
+
+See sample: [`tooltip-required-on-page-fields.bad.al`](tooltip-required-on-page-fields.bad.al).
+
+## References
+
+[ToolTip property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-tooltip-property).
+
+[Guidelines for tooltip text](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/user-assistance#guidelines-for-tooltip-text).
diff --git a/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.bad.al b/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.bad.al
new file mode 100644
index 0000000..5c9dbe7
--- /dev/null
+++ b/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.bad.al
@@ -0,0 +1,15 @@
+codeunit 50100 "Customer Lookup"
+{
+ procedure GetCustomerName(CustomerNo: Code[20]; var Name: Text[100])
+ var
+ Customer: Record Customer;
+ begin
+ if Customer.Get(CustomerNo) then
+ Name := Customer.Name;
+ end;
+
+ procedure Sample()
+ begin
+ GetCustomerName('10000', 'placeholder'); // compile error: literal is not addressable
+ end;
+}
diff --git a/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.good.al b/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.good.al
new file mode 100644
index 0000000..3c20da1
--- /dev/null
+++ b/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.good.al
@@ -0,0 +1,17 @@
+codeunit 50100 "Customer Lookup"
+{
+ procedure GetCustomerName(CustomerNo: Code[20]; var Name: Text[100])
+ var
+ Customer: Record Customer;
+ begin
+ if Customer.Get(CustomerNo) then
+ Name := Customer.Name;
+ end;
+
+ procedure Sample()
+ var
+ CustName: Text[100];
+ begin
+ GetCustomerName('10000', CustName);
+ end;
+}
diff --git a/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.md b/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.md
new file mode 100644
index 0000000..81f6fbb
--- /dev/null
+++ b/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: style
+keywords: [var-parameter, by-reference, pass-by-reference, literal, constant, compile-error, procedure-call]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# A `var` parameter must be called with a variable, never a literal or expression
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+
+When a procedure declares a parameter with `var`, that parameter is passed by reference: the callee writes back into the caller's own memory location. This means the argument at the call site must be an actual variable, something with an address. A string literal, a numeric constant, or a computed expression has no address to write back to, so passing one to a `var` parameter fails to compile. Before writing a call, check the callee's signature for `var` on each parameter position being supplied a literal or expression β if present, a variable declared in the caller's own scope must be used instead.
+
+## Best Practice
+
+Declare a variable in the caller's scope and pass it to the `var` parameter.
+
+See sample: [`var-parameters-require-an-addressable-variable.good.al`](var-parameters-require-an-addressable-variable.good.al).
+
+## Anti Pattern
+
+Passing a literal or a computed expression to a `var` parameter position fails to compile, because neither has an address the callee can write back to.
+
+See sample: [`var-parameters-require-an-addressable-variable.bad.al`](var-parameters-require-an-addressable-variable.bad.al).
diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al b/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al
deleted file mode 100644
index 3131fa6..0000000
--- a/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al
+++ /dev/null
@@ -1,13 +0,0 @@
-codeunit 50247 "Sample Var Order Bad"
-{
- procedure Run()
- var
- CustomerNo: Code[20];
- TempBuffer: Record "Integer" temporary;
- Amount: Decimal;
- Customer: Record Customer;
- IsValid: Boolean;
- begin
- IsValid := Customer.Get(CustomerNo);
- end;
-}
diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.good.al b/microsoft/knowledge/style/variable-declaration-order-by-type.good.al
deleted file mode 100644
index 590ed25..0000000
--- a/microsoft/knowledge/style/variable-declaration-order-by-type.good.al
+++ /dev/null
@@ -1,13 +0,0 @@
-codeunit 50246 "Sample Var Order Good"
-{
- procedure Run()
- var
- Customer: Record Customer;
- TempBuffer: Record "Integer" temporary;
- CustomerNo: Code[20];
- Amount: Decimal;
- IsValid: Boolean;
- begin
- IsValid := Customer.Get(CustomerNo);
- end;
-}
diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.md b/microsoft/knowledge/style/variable-declaration-order-by-type.md
deleted file mode 100644
index 3435726..0000000
--- a/microsoft/knowledge/style/variable-declaration-order-by-type.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [variable-declaration, order, var, complex-types, aa0021]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Order variable declarations by type, complex types first (CodeCop AA0021)
-
-## Description
-
-CodeCop AA0021 requires that variable declarations inside a `var` block follow a fixed ordering by type, with complex (composite) types appearing before primitive types. The canonical order is `Record`, then `Report`, `Codeunit`, `XmlPort`, `Page`, `Query`, `Notification`, `BigText`, `DateFormula`, `RecordId`, `RecordRef`, `FieldRef`, `FilterPageBuilder`, then the simple types `Text`, `Code`, `Integer`, `Decimal`, `Boolean`, `Date`, `Time`, `DateTime`, `Char`, `Byte`. Inside each type group the variables can be alphabetical or in usage order. Temporary records still sort under `Record`.
-
-## Best Practice
-
-Declare all `Record` variables first, then other complex types, then primitives. A consistent order makes diffs review-friendly and matches the convention enforced by the AL formatter and CodeCop.
-
-See sample: `variable-declaration-order-by-type.good.al`.
-
-## Anti Pattern
-
-A `var` block where records and primitives are interleaved β `CustomerNo: Code[20];` between two `Record` variables, or `Amount: Decimal;` declared above the `Customer: Record Customer;` it is computed from. AA0021 flags it and the block is harder to scan; readers expect composite types at the top.
-
-See sample: `variable-declaration-order-by-type.bad.al`.
diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al b/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al
deleted file mode 100644
index 65c8223..0000000
--- a/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al
+++ /dev/null
@@ -1,19 +0,0 @@
-codeunit 50249 "Sample Shadow Bad"
-{
- var
- Customer: Record Customer;
-
- procedure ProcessSales()
- var
- Customer: Text;
- Amount: Decimal;
- begin
- Customer := 'C-100';
- Amount := 0;
- end;
-
- procedure Amount(): Decimal
- begin
- exit(0);
- end;
-}
diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.good.al b/microsoft/knowledge/style/variable-name-must-not-shadow.good.al
deleted file mode 100644
index f5391ef..0000000
--- a/microsoft/knowledge/style/variable-name-must-not-shadow.good.al
+++ /dev/null
@@ -1,19 +0,0 @@
-codeunit 50248 "Sample No Shadow Good"
-{
- var
- CustomerRec: Record Customer;
-
- procedure ProcessSales()
- var
- CustomerName: Text;
- SalesAmount: Decimal;
- begin
- CustomerName := CustomerRec.Name;
- SalesAmount := GetAmount();
- end;
-
- procedure GetAmount(): Decimal
- begin
- exit(0);
- end;
-}
diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.md b/microsoft/knowledge/style/variable-name-must-not-shadow.md
deleted file mode 100644
index 4fee2da..0000000
--- a/microsoft/knowledge/style/variable-name-must-not-shadow.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [variable-name, shadow, conflict, aa0198, aa0202, aa0204, codecop]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Local variable names must not shadow globals, fields, methods, or actions (CodeCop AA0198/AA0202/AA0204)
-
-## Description
-
-Three CodeCop rules β AA0198, AA0202, AA0204 β together forbid a local variable from sharing a name with a global variable on the same object, with a field on the same table or page source, with a procedure on the same object, or with an action on the same page. The compiler resolves the conflict by binding the closer scope, so a local `Customer: Text` will silently override a global `Customer: Record Customer` for the duration of a procedure β every call site reading `Customer.Name` from inside that procedure refers to the text, and the breakage is invisible to a reader who has both declarations on screen.
-
-## Best Practice
-
-Differentiate every local declaration from globals, fields, procedures, and actions on the same object. `Customer` global plus `CustomerName` local; method `GetAmount` plus local `SalesAmount`. The standard pattern is to attach a noun suffix to the local (`CustomerName`, `CustomerRec`, `CustomerNo`) rather than to the global.
-
-See sample: `variable-name-must-not-shadow.good.al`.
-
-## Anti Pattern
-
-A procedure that declares a local `Customer: Text` inside a codeunit that already has a global `Customer: Record Customer`. The local wins and the global becomes unreachable inside the procedure. AA0198/AA0202/AA0204 flag this category of conflict whether the colliding entity is a global, a field, a method, or an action.
-
-See sample: `variable-name-must-not-shadow.bad.al`.
diff --git a/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md
index 940c9c8..c60887c 100644
--- a/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md
+++ b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md
@@ -17,10 +17,10 @@ application-area: [all]
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`.
+See sample: [`choose-telemetry-scope-by-audience.good.al`](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`.
+See sample: [`choose-telemetry-scope-by-audience.bad.al`](choose-telemetry-scope-by-audience.bad.al).
diff --git a/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md
index eb05742..ae632f9 100644
--- a/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md
+++ b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md
@@ -17,10 +17,10 @@ application-area: [all]
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`.
+See sample: [`feature-uptake-transitions-in-order.good.al`](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`.
+See sample: [`feature-uptake-transitions-in-order.bad.al`](feature-uptake-transitions-in-order.bad.al).
diff --git a/microsoft/knowledge/telemetry/feature-usage-only-after-success.md b/microsoft/knowledge/telemetry/feature-usage-only-after-success.md
index a1f118b..cc642d9 100644
--- a/microsoft/knowledge/telemetry/feature-usage-only-after-success.md
+++ b/microsoft/knowledge/telemetry/feature-usage-only-after-success.md
@@ -17,10 +17,10 @@ application-area: [all]
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`.
+See sample: [`feature-usage-only-after-success.good.al`](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`.
+See sample: [`feature-usage-only-after-success.bad.al`](feature-usage-only-after-success.bad.al).
diff --git a/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md
index 4c2c41a..9291219 100644
--- a/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md
+++ b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md
@@ -17,10 +17,10 @@ Business Central prefixes AL custom-dimension keys with `al` in Application Insi
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`.
+See sample: [`keep-custom-dimension-schema-stable.good.al`](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`.
+See sample: [`keep-custom-dimension-schema-stable.bad.al`](keep-custom-dimension-schema-stable.bad.al).
diff --git a/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md
index 4ff780a..cec444a 100644
--- a/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md
+++ b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md
@@ -17,10 +17,10 @@ application-area: [all]
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`.
+See sample: [`match-verbosity-to-signal-severity.good.al`](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`.
+See sample: [`match-verbosity-to-signal-severity.bad.al`](match-verbosity-to-signal-severity.bad.al).
diff --git a/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md
index d88c020..d282dc4 100644
--- a/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md
+++ b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md
@@ -17,10 +17,10 @@ The `Telemetry` and `Feature Telemetry` codeunits reach an extension publisher's
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`.
+See sample: [`register-one-telemetry-logger-per-publisher.good.al`](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`.
+See sample: [`register-one-telemetry-logger-per-publisher.bad.al`](register-one-telemetry-logger-per-publisher.bad.al).
diff --git a/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.md b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.md
index ca6d2d6..4850db5 100644
--- a/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.md
+++ b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.md
@@ -23,10 +23,10 @@ The convention used by Microsoft first-party AL code is a short prefix identifyi
Assign each `Session.LogMessage` call a real, registered event ID drawn from the extension's catalogue. Treat the ID as part of the public contract of the event β renaming it is a breaking change for consumers. Keep IDs short, deterministic, and free of personal or environment-specific tokens.
-See sample: `telemetry-event-id-stable-unique.good.al`.
+See sample: [`telemetry-event-id-stable-unique.good.al`](telemetry-event-id-stable-unique.good.al).
## Anti Pattern
Calling `Session.LogMessage('0000', ...)` (or `'1234'`, `'TODO'`, an empty string, a GUID generated at runtime, or any other placeholder) leaves the event unsearchable and indistinguishable from every other event using the same placeholder. The catalogue entry never gets created because the developer "will fix it later", and the placeholder ships.
-See sample: `telemetry-event-id-stable-unique.bad.al`.
+See sample: [`telemetry-event-id-stable-unique.bad.al`](telemetry-event-id-stable-unique.bad.al).
diff --git a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md
index 0dee645..9ebf3f8 100644
--- a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md
+++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md
@@ -17,10 +17,12 @@ application-area: [all]
Follow every `asserterror` with a verification of the error it expects, and prefer the reusable `Library Assert` helpers over hardcoded literals. For a mandatory-field check, `Assert.ExpectedTestFieldError(FieldCaption, ExpectedValue)` encapsulates both the message and the `TestField` code, so the test survives caption or code changes and does not repeat that knowledge in every method. For other errors, pair `Assert.ExpectedError` with a stable substring β ideally a shared `Label`, not an inline sentence β and, where known, `Assert.ExpectedErrorCode`. When a needed check is missing from the shared library, extend `Library Assert` (or your own assert library) with a helper rather than hardcoding message text and codes across tests; matching on a code or an invariant fragment keeps the test from going blind to the wrong error when a caption is localized.
-See sample: `asserterror-needs-expectederror-and-code.good.al`.
+See sample: [`asserterror-needs-expectederror-and-code.good.al`](asserterror-needs-expectederror-and-code.good.al).
## Anti Pattern
`asserterror DoInvalid();` with nothing after it. The test asserts only that the call failed somehow; swap the validation for a different bug and the test still passes, certifying a guard that may no longer fire. A negative test that cannot tell one error from another verifies almost nothing.
-See sample: `asserterror-needs-expectederror-and-code.bad.al`.
+Not an instance of this anti-pattern: a trailing `asserterror Error(SomeLabel)` used purely as an end-of-test rollback sentinel to undo a lazily-initialized shared fixture's scratch changes (see `commit-shared-test-fixture-inside-lazy-initialize.md`). That `Error` call exists to force a rollback, not to verify that a specific failure occurred β the sentinel's own text is not meant to be asserted against, and adding an `ExpectedError` there would just duplicate the label without checking anything the test doesn't already control.
+
+See sample: [`asserterror-needs-expectederror-and-code.bad.al`](asserterror-needs-expectederror-and-code.bad.al).
diff --git a/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.bad.al b/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.bad.al
new file mode 100644
index 0000000..d6a29d6
--- /dev/null
+++ b/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.bad.al
@@ -0,0 +1,29 @@
+// Only wraps Microsoft's own generic scenario β measures BC, not this extension
+codeunit 50101 "BCPT Create Sales Order" implements "BCPT Test Param. Provider"
+{
+ SingleInstance = true;
+
+ trigger OnRun()
+ begin
+ CreateStandardSalesOrder(GlobalBCPTTestContext);
+ end;
+
+ var
+ GlobalBCPTTestContext: Codeunit "BCPT Test Context";
+
+ local procedure CreateStandardSalesOrder(var BCPTTestContext: Codeunit "BCPT Test Context")
+ begin
+ BCPTTestContext.StartScenario('Create Sales Order With N Lines');
+ // ... standard sales order creation, no reference to the extension's own logic
+ BCPTTestContext.EndScenario('Create Sales Order With N Lines');
+ end;
+
+ procedure GetDefaultParameters(): Text[1000]
+ begin
+ exit('');
+ end;
+
+ procedure ValidateParameters(Parameters: Text[1000])
+ begin
+ end;
+}
diff --git a/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.good.al b/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.good.al
new file mode 100644
index 0000000..68239d7
--- /dev/null
+++ b/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.good.al
@@ -0,0 +1,73 @@
+codeunit 50100 "BCPT Create Service Request" implements "BCPT Test Param. Provider"
+{
+ SingleInstance = true;
+
+ trigger OnRun()
+ begin
+ if not IsInitialized then begin
+ InitTest();
+ IsInitialized := true;
+ end;
+ CreateServiceRequest(GlobalBCPTTestContext);
+ end;
+
+ var
+ GlobalBCPTTestContext: Codeunit "BCPT Test Context";
+ CustomerNo: Code[20];
+ IsInitialized: Boolean;
+
+ local procedure InitTest()
+ var
+ Customer: Record Customer;
+ begin
+ // Do not assume a customer already exists: a BCPT run may target an
+ // otherwise-empty environment. Create one if none is found instead
+ // of failing on FindFirst().
+ if not Customer.FindFirst() then begin
+ Customer.Init();
+ Customer."No." := GenerateUniqueCode(MaxStrLen(Customer."No."));
+ Customer.Insert(true);
+ end;
+ CustomerNo := Customer."No.";
+ end;
+
+ local procedure GenerateUniqueCode(Length: Integer): Code[20]
+ begin
+ // A GUID-derived code, not a session-local counter: it stays unique
+ // across concurrent BCPT sessions and repeated runs against the
+ // same environment, which an in-memory counter reset per session
+ // cannot guarantee.
+ exit(CopyStr(DelChr(Format(CreateGuid()), '=', '{}-'), 1, Length));
+ end;
+
+ local procedure CreateServiceRequest(var BCPTTestContext: Codeunit "BCPT Test Context")
+ var
+ ServiceRequestHeader: Record "Service Request Header";
+ ServiceRequestLine: Record "Service Request Line";
+ begin
+ BCPTTestContext.StartScenario('Create Service Request Header');
+ ServiceRequestHeader.Init();
+ ServiceRequestHeader."No." := GenerateUniqueCode(MaxStrLen(ServiceRequestHeader."No."));
+ ServiceRequestHeader.Validate("Customer No.", CustomerNo);
+ ServiceRequestHeader.Insert(true);
+ BCPTTestContext.EndScenario('Create Service Request Header');
+ BCPTTestContext.UserWait();
+
+ BCPTTestContext.StartScenario('Add Service Request Line');
+ ServiceRequestLine.Init();
+ ServiceRequestLine."Document No." := ServiceRequestHeader."No.";
+ ServiceRequestLine."Line No." := 10000;
+ ServiceRequestLine.Description := 'Performance test line';
+ ServiceRequestLine.Insert(true);
+ BCPTTestContext.EndScenario('Add Service Request Line');
+ end;
+
+ procedure GetDefaultParameters(): Text[1000]
+ begin
+ exit('');
+ end;
+
+ procedure ValidateParameters(Parameters: Text[1000])
+ begin
+ end;
+}
diff --git a/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.md b/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.md
new file mode 100644
index 0000000..51ebddd
--- /dev/null
+++ b/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [bcpt, performance-test, scenarios, app-specific, regression]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Include app-specific scenarios in a PerformanceTest app's BCPT suite
+
+## Description
+
+A PerformanceTest app that ships with only the generic Microsoft BCPT samples (creating sales orders, purchase orders, posting item journals) measures Business Central's own baseline performance, not the extension it was built to test. Those samples are starting points, not coverage. Without a scenario that exercises the extension's own business flow β its own codeunits, its own FlowFields, its own page rendering β a performance regression introduced by the extension has no test that would ever detect it.
+
+## Best Practice
+
+For every major business flow the extension adds, create a matching `BCPT*` scenario codeunit implementing `"BCPT Test Param. Provider"`, building its own test data in a local `InitTest()` procedure rather than depending on hardcoded records. Beyond that shared shape, the interface details are context-dependent, not fixed requirements: most of Microsoft's own shipped BCPT samples declare `SingleInstance = true`, but `codeunit "BCPT Create Customer"` does not, relying instead on `OnRun` calling `InitTest()` unconditionally every run. Likewise, wrapping the operation under test in `BCPTTestContext.StartScenario()` / `EndScenario()` is a real, available pattern for splitting one codeunit's run into several separately measured steps β useful when a regression in one step should not hide inside a coarser, whole-`OnRun` measurement β but it is not what every sample does; `"BCPT Create Customer"` measures its entire `OnRun` as a single implicit scenario and never calls `StartScenario`/`EndScenario` at all. Choose per-step scenarios when step-level granularity matters to the flow being tested; otherwise a single measured `OnRun` is a legitimate, simpler choice.
+
+See sample: [`bcpt-scenarios-must-be-app-specific.good.al`](bcpt-scenarios-must-be-app-specific.good.al).
+
+## Anti Pattern
+
+A PerformanceTest app whose only scenario codeunits are copies of Microsoft's shipped samples (creating a standard sales order, opening the standard customer list) tests the platform, not the extension. Any regression in the extension's own posting logic, calculations, or pages goes unmeasured and unnoticed.
+
+See sample: [`bcpt-scenarios-must-be-app-specific.bad.al`](bcpt-scenarios-must-be-app-specific.bad.al).
diff --git a/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.bad.al b/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.bad.al
new file mode 100644
index 0000000..f2af637
--- /dev/null
+++ b/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.bad.al
@@ -0,0 +1,73 @@
+codeunit 50142 "Sample Test Library"
+{
+ Subtype = Test;
+
+ var
+ LibraryInventory: Codeunit "Library - Inventory";
+ Initialized: Boolean;
+ SharedItemNo: Code[20];
+ RollBackMsg: Label 'Revert back the tables to their original state.';
+
+ local procedure Initialize()
+ begin
+ if Initialized then
+ exit;
+
+ CreateSharedFixtureData();
+ // BUG: no Commit() here. The fixture below is still inside this
+ // test method's own transaction.
+ Initialized := true;
+ end;
+
+ local procedure CreateSharedFixtureData()
+ var
+ Item: Record Item;
+ begin
+ LibraryInventory.CreateItem(Item);
+ SharedItemNo := Item."No.";
+ end;
+
+ [Test]
+ procedure FirstTestUsesSharedFixture()
+ var
+ Item: Record Item;
+ begin
+ Initialize();
+
+ Item.Get(SharedItemNo);
+ Item.Description := 'Scratch change this test makes and does not need to keep.';
+ Item.Modify();
+
+ asserterror Error(RollBackMsg);
+ // The deliberate rollback above also erases the never-committed
+ // fixture from CreateSharedFixtureData(). Initialized still reads
+ // true on the next test, but the row it points at is gone.
+ end;
+
+ [Test]
+ procedure SecondTestStillFindsSharedFixture()
+ var
+ Item: Record Item;
+ begin
+ Initialize();
+
+ // Fails here: Initialize() saw Initialized = true and returned
+ // immediately, so it never recreated the fixture - and the first
+ // test's rollback took the original row with it.
+ Item.Get(SharedItemNo);
+ end;
+}
+
+codeunit 50143 "Sample Test Runner"
+{
+ // Codeunit isolation alone does not save this fixture: TestIsolation
+ // only controls whether committed changes survive between methods, and
+ // this fixture was never committed in the first place.
+ Subtype = TestRunner;
+ TestIsolation = Codeunit;
+
+ trigger OnRun()
+ begin
+ Codeunit.Run(Codeunit::"Sample Test Library");
+ end;
+}
diff --git a/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.good.al b/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.good.al
new file mode 100644
index 0000000..c550697
--- /dev/null
+++ b/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.good.al
@@ -0,0 +1,71 @@
+codeunit 50142 "Sample Test Library"
+{
+ Subtype = Test;
+
+ var
+ LibraryInventory: Codeunit "Library - Inventory";
+ Initialized: Boolean;
+ SharedItemNo: Code[20];
+ RollBackMsg: Label 'Revert back the tables to their original state.';
+
+ local procedure Initialize()
+ begin
+ if Initialized then
+ exit;
+
+ CreateSharedFixtureData();
+ Commit();
+ Initialized := true;
+ end;
+
+ local procedure CreateSharedFixtureData()
+ var
+ Item: Record Item;
+ begin
+ LibraryInventory.CreateItem(Item);
+ SharedItemNo := Item."No.";
+ end;
+
+ [Test]
+ procedure FirstTestUsesSharedFixture()
+ var
+ Item: Record Item;
+ begin
+ Initialize();
+
+ Item.Get(SharedItemNo);
+ Item.Description := 'Scratch change this test makes and does not need to keep.';
+ Item.Modify();
+
+ asserterror Error(RollBackMsg);
+ // Rolls back the Modify() above, but not the fixture: that was
+ // already committed inside Initialize().
+ end;
+
+ [Test]
+ procedure SecondTestStillFindsSharedFixture()
+ var
+ Item: Record Item;
+ begin
+ // Runs after FirstTestUsesSharedFixture's deliberate rollback.
+ // Initialize() sees Initialized = true and does nothing, but the
+ // committed fixture it created earlier is still there to Get().
+ Initialize();
+
+ Item.Get(SharedItemNo);
+ end;
+}
+
+codeunit 50143 "Sample Test Runner"
+{
+ // Codeunit isolation: everything this codeunit's tests commit,
+ // including the shared fixture, survives from one test method to the
+ // next, and rolls back only once every method in the codeunit has run.
+ Subtype = TestRunner;
+ TestIsolation = Codeunit;
+
+ trigger OnRun()
+ begin
+ Codeunit.Run(Codeunit::"Sample Test Library");
+ end;
+}
diff --git a/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.md b/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.md
new file mode 100644
index 0000000..7ec16df
--- /dev/null
+++ b/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.md
@@ -0,0 +1,34 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [initialize, isinitialized, shared-fixture, commit, autocommit, asserterror, testisolation, lazy-initialization]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Commit shared fixture data created inside a lazy Initialize(), or later tests lose it
+
+## Description
+
+A test method with no `[TransactionModel(...)]` attribute defaults to `AutoCommit` (see `transactionmodel-attribute-governs-test-transactions.md`): a method that completes without error commits automatically at its own boundary, with no explicit `Commit()` needed. So a lazy/shared `Initialize()` β guarded by an `IsInitialized` flag, creating master/setup data once to avoid repeating expensive setup across many `[Test]` methods β does not need `Commit()` just to survive into the next test method; under the default model it already will. (Declaring `[TransactionModel(AutoRollback)]` instead is not compatible with this pattern at all: `AutoRollback` assumes the code under test never commits, and a `Commit()` call under it raises a runtime error.)
+
+What an early `Commit()` inside `Initialize()` actually guards against is the test method's *own later, deliberate* rollback β the BCApps cleanup idiom of ending a test with `asserterror Error(SomeLabel)` to undo demo-data mutations that method made, so the run doesn't permanently dirty the database. Per the documented `Codeunit.Run` transaction semantics, changes are committed at the end of an execution "unless an error occurs" β an unhandled error rolls back whatever wasn't already committed. `Commit()` closes out the fixture's own transaction immediately, so it is unaffected by whatever the rest of that method does afterward, including that end-of-test error. Without the early `Commit()`, the same deliberate rollback wipes out the fixture too, even though `IsInitialized` still reads `true` on the next test, since it's a plain variable, not persisted data. BCApps' `codeunit 134915 "ERM Online Mapping Setup"` shows exactly this shape: no `TransactionModel` attribute, `Commit()` inside a lazy `Initialize()`, and the test itself ends with `asserterror Error(RollBackMessage)`.
+
+Protecting the fixture from that same-method rollback is necessary but not sufficient for the fixture to reach a *later* test method β that also depends on the executing test runner's `TestIsolation`. Under `Disabled` (the property's own documented default) or `Codeunit` (used by BCApps' own `TestRunner`, `CLITestRunner`, and `SnapTestRunner` codeunits), nothing rolls back until the whole test codeunit finishes, so the already-committed fixture survives across every method run before then. These two are not interchangeable, though: `Codeunit` rolls back everything once the codeunit's last method completes, so the environment is clean afterward; `Disabled` never rolls back anything at all β "tests are not isolated from each other" is the property's own description β so a fixture this pattern commits stays in the database permanently unless something else explicitly deletes it. Under `Function`, the runner rolls back all database changes β explicitly including ones already committed via `Commit()` β after every single test method; no amount of committing inside `Initialize()` makes a fixture shared across methods survive that regime, because the whole premise of a lazy, once-per-codeunit fixture doesn't hold when every method is isolated from every other.
+
+## Best Practice
+
+When a test method's own cleanup relies on ending in a deliberate error to roll back its scratch changes, call `Commit()` once, inside the lazy `Initialize()` guard, right after the shared fixture is created β before that cleanup-triggering error can run. This pattern only delivers a fixture shared across test methods when the executing runner's `TestIsolation` is `Disabled` or `Codeunit`; do not recommend it, or pair it with, a `Function`-isolated runner β that configuration undoes the committed fixture after every method regardless. Recommend `TestIsolation = Codeunit`: it gives every method in the codeunit the same shared, committed fixture and still leaves the database clean once the codeunit finishes. Recommend `Disabled` only alongside an explicit, verified teardown step that removes the fixture data at the end of the run β without one, the committed fixture is permanent contamination, not a controlled trade-off.
+
+See sample: [`commit-shared-test-fixture-inside-lazy-initialize.good.al`](commit-shared-test-fixture-inside-lazy-initialize.good.al).
+
+## Anti Pattern
+
+A shared `Initialize()` guarded by `IsInitialized` that creates fixture records without committing, in a test method that ends with a deliberate `asserterror Error(...)` to undo its own scratch changes, run under a `Disabled`- or `Codeunit`-isolated test runner. That rollback also erases the never-committed fixture; the next test still finds `IsInitialized = true` but the rows it depends on are gone. (Under a `Function`-isolated runner the fixture is lost regardless of `Commit()`, for the unrelated reason above β that is a runner-configuration problem, not this anti-pattern.)
+
+See sample: [`commit-shared-test-fixture-inside-lazy-initialize.bad.al`](commit-shared-test-fixture-inside-lazy-initialize.bad.al).
+
+## Source
+
+The shared/lazy `Initialize()` pattern and its `Commit()` call are drawn from Luc van Vugt's "Let's talk about Shared Fixture and how to profit from this with the Dynamics NAV Test Toolkit": https://www.fluxxus.nl/index.php/bc/let39s-talk-about-shared-fixture-and-how-to-profit-from-this-with-the-dynamics-nav-test-toolkit/. That post shows the `Commit()` call in its `Initialize()` example but does not explain the transaction mechanics behind it; the `AutoCommit`-default, `Codeunit.Run`-error, and `TestIsolation`-level analysis above is this article's own, verified independently against Microsoft's TransactionModel/TestIsolation documentation and BCApps' `codeunit 134915 "ERM Online Mapping Setup"` source, not taken from the post.
diff --git a/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.bad.al b/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.bad.al
new file mode 100644
index 0000000..0181fdb
--- /dev/null
+++ b/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.bad.al
@@ -0,0 +1,15 @@
+[Test]
+procedure PostSalesOrder_CreatesInvoice()
+var
+ SalesHeader: Record "Sales Header";
+ SalesInvoiceHeader: Record "Sales Invoice Header";
+ InvoiceNo: Code[20];
+begin
+ // [GIVEN] a sales order β posting groups left to whatever exists in the test company
+ LibrarySales.CreateSalesOrder(SalesHeader);
+ // [WHEN]
+ InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, false, true);
+ // [THEN]
+ SalesInvoiceHeader.Get(InvoiceNo);
+ Assert.RecordIsNotEmpty(SalesInvoiceHeader);
+end;
diff --git a/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.good.al b/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.good.al
new file mode 100644
index 0000000..224f0dc
--- /dev/null
+++ b/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.good.al
@@ -0,0 +1,20 @@
+[Test]
+procedure PostSalesOrder_CreatesInvoice()
+var
+ Customer: Record Customer;
+ SalesHeader: Record "Sales Header";
+ SalesInvoiceHeader: Record "Sales Invoice Header";
+ InvoiceNo: Code[20];
+begin
+ // [GIVEN] a customer
+ LibrarySales.CreateCustomer(Customer);
+ // [GIVEN] a sales order for that customer
+ LibrarySales.CreateSalesOrderForCustomerNo(SalesHeader, Customer."No.");
+ SalesHeader.Validate("Posting Date", WorkDate());
+ SalesHeader.Modify(true);
+ // [WHEN]
+ InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, false, true);
+ // [THEN]
+ SalesInvoiceHeader.Get(InvoiceNo);
+ Assert.RecordIsNotEmpty(SalesInvoiceHeader);
+end;
diff --git a/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.md b/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.md
new file mode 100644
index 0000000..b34adfe
--- /dev/null
+++ b/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [given, test-setup, posting, report, request-page, precondition, completeness]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Cover the full precondition chain in GIVEN, not just the primary record
+
+## Description
+
+A `[GIVEN]` block is only correct if it sets up every precondition the code under test actually reads, not just the record the scenario is "about." For most master-data tests, creating the primary record is enough. For posting routines and reports it usually is not: an incomplete `[GIVEN]` produces a test that either fails with a setup error unrelated to the scenario, or worse, passes without ever reaching the logic it claims to verify.
+
+## Best Practice
+
+For a posting test, set up the full posting-group chain the document requires (e.g. customer/vendor posting group, gen. business/product posting group, VAT posting setup), the setup records the specific posting path reads, and an explicit date when the path is date-sensitive β a missing link surfaces as an unrelated G/L error, not a meaningful test failure. For a report test that claims to verify filtering or dataset logic, include both a record that should be included and one that should be excluded, plus any request-page parameter or FlowField the report's logic branches on. A report test that only claims to run without error is exempt from the include/exclude pairing, but it must say so in its scenario name or comment β an unlabelled single-record `[GIVEN]` is ambiguous about which claim it is making, and that ambiguity is itself the defect.
+
+See sample: [`given-blocks-must-cover-full-precondition-chain.good.al`](given-blocks-must-cover-full-precondition-chain.good.al).
+
+## Anti Pattern
+
+A posting test whose `[GIVEN]` creates only the sales header, relying on whatever posting groups happen to exist in the test company. A report test whose `[GIVEN]` creates only matching records, so the report "passes" whether or not its filter logic does anything at all.
+
+See sample: [`given-blocks-must-cover-full-precondition-chain.bad.al`](given-blocks-must-cover-full-precondition-chain.bad.al).
diff --git a/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md
index e53986b..1722578 100644
--- a/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md
+++ b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md
@@ -19,10 +19,10 @@ What matters is the effective permission context at the moment the protected ope
Use `TestPermissions::Restrictive` for a permission-sensitive test and lower the current test user with the test framework's `"Permissions Mock"` or `"Library - Lower Permissions"` before invoking the protected operation. Assign a permission context that actually contains the rights the scenario tests β either the permission set itself or a role that includes it β and restore or stop the mock afterward. Use `Disabled` only for suites that do not assert permission behavior, or where the test lowers the context explicitly through the test libraries instead of relying on the runner. Do not require a test to apply the permission set under test directly when it reaches the same rights through a composed role and then asserts the boundary.
-See sample: `permission-tests-must-lower-the-execution-context.good.al`.
+See sample: [`permission-tests-must-lower-the-execution-context.good.al`](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. Do not report the mirror image: a test that lowers the context through a role including the permission set under test, and then asserts the boundary, has exercised that permission set and is not a coverage gap.
-See sample: `permission-tests-must-lower-the-execution-context.bad.al`.
+See sample: [`permission-tests-must-lower-the-execution-context.bad.al`](permission-tests-must-lower-the-execution-context.bad.al).
diff --git a/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.bad.al b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.bad.al
new file mode 100644
index 0000000..afebd56
--- /dev/null
+++ b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.bad.al
@@ -0,0 +1,57 @@
+codeunit 50411 "Test Sales Setup Initialize Bad"
+{
+ Subtype = Test;
+
+ [Test]
+ [HandlerFunctions('CustomerCardHandler')]
+ procedure CustomerCardOpensForSelectedCustomer()
+ var
+ Customer: Record Customer;
+ begin
+ Initialize();
+ LibrarySales.CreateCustomer(Customer);
+ LibraryVariableStorage.Enqueue(Customer."No.");
+
+ Page.RunModal(Page::"Customer Card", Customer);
+
+ LibraryVariableStorage.AssertEmpty();
+ end;
+
+ [Test]
+ procedure StockoutWarningCanBeDisabled()
+ var
+ SalesSetup: Record "Sales & Receivables Setup";
+ begin
+ Initialize();
+
+ LibrarySales.SetStockoutWarning(false);
+
+ SalesSetup.Get();
+ Assert.IsFalse(SalesSetup."Stockout Warning", 'The stockout warning was not disabled.');
+ end;
+
+ local procedure Initialize()
+ begin
+ if IsInitialized then
+ exit;
+
+ LibraryVariableStorage.Clear();
+ LibrarySetupStorage.Restore();
+ LibrarySales.SetStockoutWarning(true);
+ IsInitialized := true;
+ LibrarySetupStorage.SaveSalesSetup();
+ end;
+
+ [ModalPageHandler]
+ procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card")
+ begin
+ Assert.AreEqual(LibraryVariableStorage.DequeueText(), CustomerCard."No.".Value(), 'The customer card opened for the wrong customer.');
+ end;
+
+ var
+ Assert: Codeunit Assert;
+ LibrarySales: Codeunit "Library - Sales";
+ LibrarySetupStorage: Codeunit "Library - Setup Storage";
+ LibraryVariableStorage: Codeunit "Library - Variable Storage";
+ IsInitialized: Boolean;
+}
diff --git a/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.good.al b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.good.al
new file mode 100644
index 0000000..0d1d7c7
--- /dev/null
+++ b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.good.al
@@ -0,0 +1,62 @@
+codeunit 50410 "Test Sales Setup Initialize Good"
+{
+ Subtype = Test;
+
+ [Test]
+ [HandlerFunctions('CustomerCardHandler')]
+ procedure CustomerCardOpensForSelectedCustomer()
+ var
+ Customer: Record Customer;
+ begin
+ Initialize();
+ LibrarySales.CreateCustomer(Customer);
+ LibraryVariableStorage.Enqueue(Customer."No.");
+
+ Page.RunModal(Page::"Customer Card", Customer);
+
+ LibraryVariableStorage.AssertEmpty();
+ end;
+
+ [Test]
+ procedure StockoutWarningCanBeDisabled()
+ var
+ SalesSetup: Record "Sales & Receivables Setup";
+ begin
+ Initialize();
+
+ LibrarySales.SetStockoutWarning(false);
+
+ SalesSetup.Get();
+ Assert.IsFalse(SalesSetup."Stockout Warning", 'The stockout warning was not disabled.');
+ end;
+
+ local procedure Initialize()
+ begin
+ LibraryTestInitialize.OnTestInitialize(Codeunit::"Test Sales Setup Initialize Good");
+ LibraryVariableStorage.Clear();
+ LibrarySetupStorage.Restore();
+
+ if IsInitialized then
+ exit;
+ LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Test Sales Setup Initialize Good");
+
+ LibrarySales.SetStockoutWarning(true);
+ IsInitialized := true;
+ LibrarySetupStorage.SaveSalesSetup();
+ LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Test Sales Setup Initialize Good");
+ end;
+
+ [ModalPageHandler]
+ procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card")
+ begin
+ Assert.AreEqual(LibraryVariableStorage.DequeueText(), CustomerCard."No.".Value(), 'The customer card opened for the wrong customer.');
+ end;
+
+ var
+ Assert: Codeunit Assert;
+ LibrarySales: Codeunit "Library - Sales";
+ LibrarySetupStorage: Codeunit "Library - Setup Storage";
+ LibraryTestInitialize: Codeunit "Library - Test Initialize";
+ LibraryVariableStorage: Codeunit "Library - Variable Storage";
+ IsInitialized: Boolean;
+}
diff --git a/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.md b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.md
new file mode 100644
index 0000000..2d2c417
--- /dev/null
+++ b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.md
@@ -0,0 +1,41 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [initialize, isinitialized, library-test-initialize, ontestinitialize, library-variable-storage, library-setup-storage, test-fixture, test-codeunit]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Reset per-test state before the IsInitialized guard
+
+## Description
+
+Standard Business Central test codeunits call a local `Initialize` procedure at the start of every test method. Global variables in a test codeunit keep their values between the codeunit's test methods, so a Boolean such as `IsInitialized` lets `Initialize` run expensive shared setup only once. The procedure therefore has two parts with different lifetimes: work that must run before **every** test, and one-time setup behind the guard. If per-test reset is placed after the guard, it runs only for the first test. Values left in `Library - Variable Storage` by a failed test, or setup records a test changed, then leak into later tests, which pass or fail depending on execution order.
+
+## Best Practice
+
+Call `Initialize()` as the first statement of every test method. Inside it, keep this order, which the Base Application tests follow:
+
+1. Per-test work, before the guard: raise `"Library - Test Initialize".OnTestInitialize`, call `LibraryVariableStorage.Clear()`, and call `LibrarySetupStorage.Restore()` when setup tables were saved.
+2. `if IsInitialized then exit;`
+3. One-time work: raise `OnBeforeTestSuiteInitialize`, create the shared fixture and setup values, set `IsInitialized := true`, save the setup tables that tests may change (for example `LibrarySetupStorage.SaveSalesSetup()`), and raise `OnAfterTestSuiteInitialize`.
+
+Create data that a single test changes inside that test, not in the shared fixture. Base Application suites also commit after the one-time setup so the shared fixture survives each test's transaction; whether that commit is valid depends on the test transaction model and runner isolation, see [`transactionmodel-attribute-governs-test-transactions.md`](transactionmodel-attribute-governs-test-transactions.md) and [`testisolation-belongs-on-the-test-runner.md`](testisolation-belongs-on-the-test-runner.md).
+
+A test codeunit with no shared setup and no queued values doesn't need an `Initialize` procedure. Don't report its absence on its own.
+
+See sample: [`reset-per-test-state-before-the-isinitialized-guard.good.al`](reset-per-test-state-before-the-isinitialized-guard.good.al).
+
+## Anti Pattern
+
+`if IsInitialized then exit;` as the first statement of `Initialize`, followed by `LibraryVariableStorage.Clear()`, `LibrarySetupStorage.Restore()`, or other reset calls that are then skipped for every test after the first. A related defect is a test method in a codeunit that uses the pattern but doesn't call `Initialize()`, so it runs with whatever state the previous test left. Detection signal: in a `Subtype = Test` codeunit, a reset call placed after the `IsInitialized` exit, or a `[Test]` procedure that uses shared globals or queued values without first calling `Initialize()`.
+
+See sample: [`reset-per-test-state-before-the-isinitialized-guard.bad.al`](reset-per-test-state-before-the-isinitialized-guard.bad.al).
+
+## References
+
+- [BCApps: `Initialize` in the ERM Sales Document tests](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/Tests/ERM-Sales/ERMSalesDocument.Codeunit.al)
+- [BCApps: Library - Test Initialize events](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/Tests/ApplicationTestLibrary/LibraryTestInitialize.Codeunit.al)
+- [BCApps: Library - Setup Storage](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/Tests/ApplicationTestLibrary/LibrarySetupStorage.Codeunit.al)
+- [Testing the application](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-testing-application)
diff --git a/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.bad.al b/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.bad.al
new file mode 100644
index 0000000..d95d9f2
--- /dev/null
+++ b/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.bad.al
@@ -0,0 +1,47 @@
+table 50144 "Sample Setup"
+{
+ fields
+ {
+ field(1; "Primary Key"; Code[10]) { }
+ field(2; "Default Category Code"; Code[20]) { }
+ }
+ keys
+ {
+ key(PK; "Primary Key") { Clustered = true; }
+ }
+}
+
+table 50145 "Sample Header"
+{
+ fields
+ {
+ field(1; "No."; Code[20]) { }
+ field(10; "Category Code"; Code[10])
+ {
+ TableRelation = "Sample Setup"."Primary Key";
+ }
+ field(11; "Parent No."; Code[20])
+ {
+ TableRelation = "Sample Header"."No.";
+ }
+ }
+ keys
+ {
+ key(PK; "No.") { Clustered = true; }
+ }
+}
+
+codeunit 50141 "Sample Table Relation Test Ext"
+{
+ [EventSubscriber(ObjectType::Codeunit, Codeunit::"Table Relation Test", 'OnAfterRemoveTableRelation', '', false, false)]
+ local procedure ExcludeSampleFieldFromTableRelationTest(var TableRelationsMetadata: Record "Table Relations Metadata" temporary)
+ var
+ TableRelationTest: Codeunit "Table Relation Test";
+ begin
+ // Removes every relation on the whole table (field/related table/
+ // related field all 0), not just the one known exception - this
+ // also strips "Parent No." -> "Sample Header"."No.", which had no
+ // exception and should have stayed covered by the standard test.
+ TableRelationTest.RemoveTableRelation(TableRelationsMetadata, Database::"Sample Header", 0, 0, 0);
+ end;
+}
diff --git a/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.good.al b/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.good.al
new file mode 100644
index 0000000..26db161
--- /dev/null
+++ b/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.good.al
@@ -0,0 +1,51 @@
+table 50144 "Sample Setup"
+{
+ fields
+ {
+ field(1; "Primary Key"; Code[10]) { }
+ field(2; "Default Category Code"; Code[20]) { }
+ }
+ keys
+ {
+ key(PK; "Primary Key") { Clustered = true; }
+ }
+}
+
+table 50145 "Sample Header"
+{
+ fields
+ {
+ field(1; "No."; Code[20]) { }
+ // A known exception: "Category Code" predates "Sample Setup" and
+ // can carry a value that no longer resolves to a real row there,
+ // so the standard Table Relation Test would otherwise reject it -
+ // excluded via OnAfterRemoveTableRelation below.
+ field(10; "Category Code"; Code[10])
+ {
+ TableRelation = "Sample Setup"."Primary Key";
+ }
+ // An ordinary relation with no exception - ExcludeSampleFieldFrom
+ // TableRelationTest below must leave this one checked.
+ field(11; "Parent No."; Code[20])
+ {
+ TableRelation = "Sample Header"."No.";
+ }
+ }
+ keys
+ {
+ key(PK; "No.") { Clustered = true; }
+ }
+}
+
+codeunit 50141 "Sample Table Relation Test Ext"
+{
+ [EventSubscriber(ObjectType::Codeunit, Codeunit::"Table Relation Test", 'OnAfterRemoveTableRelation', '', false, false)]
+ local procedure ExcludeSampleFieldFromTableRelationTest(var TableRelationsMetadata: Record "Table Relations Metadata" temporary)
+ var
+ TableRelationTest: Codeunit "Table Relation Test";
+ begin
+ // Removes only the one known exception. "Parent No." -> "Sample
+ // Header"."No." is untouched and stays covered by the standard test.
+ TableRelationTest.RemoveTableRelation(TableRelationsMetadata, Database::"Sample Header", 10, Database::"Sample Setup", 1);
+ end;
+}
diff --git a/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.md b/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.md
new file mode 100644
index 0000000..3f7d778
--- /dev/null
+++ b/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.md
@@ -0,0 +1,35 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [table-relation-test, tablerelationsmetadata, onafterremovetablerelation, field-length, field-type]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Exclude a known-valid TableRelation exception via OnAfterRemoveTableRelation
+
+## Description
+
+Codeunit 134926 "Table Relation Test" (shipped in BCApps' test app β only consumers that depend on the BC test libraries can subscribe to it) reads Table Relations Metadata tenant-wide across every installed app, not just the current one, and validates each field's type and length against what its relations require β but the exact rule depends on whether that field has an *unconditional* relation (a `Table Relations Metadata` row with `Condition Field No. = 0`) among its relations, or only *conditional* ones:
+
+- If any relation is unconditional, the field's length must equal *exactly* the largest related field's length, and its type must exactly match the required type β resolved to `Text` when the related fields themselves mix `Code` and `Text`.
+- If every relation for that field is conditional, the requirement relaxes: the field only needs to be *at least* as long as the largest related field (longer is accepted; only shorter fails), and when the required type is specifically `Code`, both a `Code` and a `Text` source field pass. That `Code`/`Text` tolerance is conditional-only β it does not apply on the unconditional side, and it does not extend to a required type of `Text` (a `Code` source field does not satisfy a required `Text`).
+
+A field with a legitimate, intentional relation shape outside both of these tolerances has no per-field override in its own object definition; the check runs with no built-in escape hatch beyond them. The validation test method itself is `[Scope('OnPrem')]`: it only runs from an on-premises test surface, not from a cloud-targeted test app, so this whole exception mechanism β and the check it works around β is only reachable where that test can actually execute.
+
+## Best Practice
+
+Subscribe to `OnAfterRemoveTableRelation` and call the codeunit's own `RemoveTableRelation(TableRelationsMetadata, TableID, FieldID, RelatedTableID, RelatedFieldID)` to strike the one known-valid relation before the test evaluates it, scoped as narrowly as the exception actually is. Because the test itself is `[Scope('OnPrem')]`, do not recommend subscribing to it as a way to guard a cloud-targeted app's test suite β the subscription has no effect where the test never runs.
+
+See sample: [`table-relation-test-exclude-known-invalid-relations-via-event.good.al`](table-relation-test-exclude-known-invalid-relations-via-event.good.al).
+
+## Anti Pattern
+
+Excluding an entire table's relations (or disabling the whole test codeunit) to work around one known exception. This discards the check's coverage for every other relation on that table, or in the app, not just the one that needed an exception.
+
+See sample: [`table-relation-test-exclude-known-invalid-relations-via-event.bad.al`](table-relation-test-exclude-known-invalid-relations-via-event.bad.al).
+
+## Source
+
+The `OnAfterRemoveTableRelation` exclusion technique is drawn from Luc van Vugt's "How-to: Test your Table Relations (2)": https://www.fluxxus.nl/index.php/bc/how-to-test-your-table-relations-2/. The codeunit/event signature, the `[Scope('OnPrem')]` boundary, and the tenant-wide `Table Relations Metadata` scope described above were verified directly against BCApps' `codeunit 134926 "Table Relation Test"` source, not taken from the post.
diff --git a/microsoft/knowledge/testing/test-data-must-be-random-and-complete.bad.al b/microsoft/knowledge/testing/test-data-must-be-random-and-complete.bad.al
new file mode 100644
index 0000000..34c13e4
--- /dev/null
+++ b/microsoft/knowledge/testing/test-data-must-be-random-and-complete.bad.al
@@ -0,0 +1,14 @@
+[Test]
+procedure PostsSalesOrderForCashCustomer()
+var
+ Customer: Record Customer;
+ SalesHeader: Record "Sales Header";
+begin
+ // Assumes a 'CASH' customer already exists in the environment β
+ // fails on any database where it doesn't.
+ Customer.Get('CASH');
+ LibrarySales.CreateSalesHeader(
+ SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
+
+ // ... add lines, post, assert ...
+end;
diff --git a/microsoft/knowledge/testing/test-data-must-be-random-and-complete.good.al b/microsoft/knowledge/testing/test-data-must-be-random-and-complete.good.al
new file mode 100644
index 0000000..75518f1
--- /dev/null
+++ b/microsoft/knowledge/testing/test-data-must-be-random-and-complete.good.al
@@ -0,0 +1,13 @@
+[Test]
+procedure PostsSalesOrderForRandomCustomer()
+var
+ Customer: Record Customer;
+ SalesHeader: Record "Sales Header";
+begin
+ // Freshly created customer, owned by this test β no assumption about what exists.
+ LibrarySales.CreateCustomer(Customer);
+ LibrarySales.CreateSalesHeader(
+ SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
+
+ // ... add lines, post, assert ...
+end;
diff --git a/microsoft/knowledge/testing/test-data-must-be-random-and-complete.md b/microsoft/knowledge/testing/test-data-must-be-random-and-complete.md
new file mode 100644
index 0000000..c78a0d5
--- /dev/null
+++ b/microsoft/knowledge/testing/test-data-must-be-random-and-complete.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [testing, test-data, random, library, any]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Generate Test Data Programmatically, Never Assume Existing Records
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+
+A BC test company normally contains initialized system/setup data β an AL test suite should not assume an empty database, but it must be independent of unrelated business records: create the records and setup it owns rather than looking up a specific code, number, or name assumed to already exist, since that makes the test fail for reasons unrelated to the code under test. Every mandatory field on a created record also needs an actual value β leaving one blank because setup-time validation happens to allow it produces a record that doesn't reflect a real one and can fail later, elsewhere in the flow (posting, a report, a later assertion), for a reason unrelated to what the test claims to check. A short-but-valid value is not itself a defect: AL field lengths are maxima, not minimums, so a two-character value in a `Text[100]` field is fine unless the scenario specifically depends on the field's length or shape β for example, a test that verifies truncation or a format check needs a value chosen to exercise that boundary, not an arbitrary short one.
+
+Not every value should be generated, though. Incidental fixture data β identifiers, names, descriptions β should generally come from the standard library codeunits rather than be tied to specific existing data. But values that materially define the scenario under test β amounts, quantities, percentages, dates, thresholds, rounding precision β should stay explicit and deliberately chosen, not randomized: a rounding test needs values placed deliberately around the rounding boundary, not a random one that might miss it entirely.
+
+## Best Practice
+
+Use the standard library codeunits (`Library - ERM`, `Library - Inventory`, `Library - Sales`, `Library - Utility`) to generate incidental fixture values β they produce valid, unique-enough data via number series and controlled randomness, not a mathematical collision-free guarantee β and fill every mandatory field with correctly-sized data. Keep values that define the scenario's expected outcome explicit and fixed. Reserve hardcoded values for tests that validate an external contract itself β a fixed JSON schema, an EDIFACT message, a counterparty code β where the hardcoded value documents the specification rather than arbitrary test logic.
+
+See sample: [`test-data-must-be-random-and-complete.good.al`](test-data-must-be-random-and-complete.good.al).
+
+## Anti Pattern
+
+Looking up a record assumed to already exist (a hardcoded payment method or customer number) instead of creating it, or leaving a mandatory field empty because setup-time validation happens to allow it. Also an anti-pattern, narrower: using a value that doesn't satisfy a scenario's explicit length or format requirement β for example a truncation test that never actually exceeds the field it's meant to overflow.
+
+See sample: [`test-data-must-be-random-and-complete.bad.al`](test-data-must-be-random-and-complete.bad.al).
diff --git a/microsoft/knowledge/testing/test-feature-scenario-tags.bad.al b/microsoft/knowledge/testing/test-feature-scenario-tags.bad.al
new file mode 100644
index 0000000..06e5d54
--- /dev/null
+++ b/microsoft/knowledge/testing/test-feature-scenario-tags.bad.al
@@ -0,0 +1,33 @@
+codeunit 50102 "Item Price Testing"
+{
+ Subtype = Test;
+
+ var
+ LibrarySales: Codeunit "Library - Sales";
+ LibraryInventory: Codeunit "Library - Inventory";
+ LibraryPriceCalculation: Codeunit "Library - Price Calculation";
+ Assert: Codeunit "Library Assert";
+
+ [Test]
+ procedure Test1()
+ var
+ Customer: Record Customer;
+ Item: Record Item;
+ PriceListHeader: Record "Price List Header";
+ PriceListLine: Record "Price List Line";
+ SalesHeader: Record "Sales Header";
+ SalesLine: Record "Sales Line";
+ begin
+ // setup mixed with assertions, no clear layers, no FEATURE/SCENARIO/GIVEN/WHEN/THEN tags
+ LibrarySales.CreateCustomer(Customer);
+ LibraryInventory.CreateItem(Item);
+ LibraryPriceCalculation.CreatePriceHeader(
+ PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
+ LibraryPriceCalculation.CreateSalesPriceLine(
+ PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
+ "Price Asset Type"::Item, Item."No.");
+ LibrarySales.CreateSalesDocumentWithItem(
+ SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
+ Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", '');
+ end;
+}
diff --git a/microsoft/knowledge/testing/test-feature-scenario-tags.good.al b/microsoft/knowledge/testing/test-feature-scenario-tags.good.al
new file mode 100644
index 0000000..b2cbf06
--- /dev/null
+++ b/microsoft/knowledge/testing/test-feature-scenario-tags.good.al
@@ -0,0 +1,40 @@
+// [FEATURE] Item Price β price cascade (Customer -> Price Group -> All Customers)
+codeunit 50103 "Item Price Testing"
+{
+ Subtype = Test;
+
+ var
+ LibrarySales: Codeunit "Library - Sales";
+ LibraryInventory: Codeunit "Library - Inventory";
+ LibraryPriceCalculation: Codeunit "Library - Price Calculation";
+ Assert: Codeunit "Library Assert";
+
+ [Test]
+ procedure GetPrice_CustomerPrice_ReturnsUnitPrice()
+ var
+ Customer: Record Customer;
+ Item: Record Item;
+ PriceListHeader: Record "Price List Header";
+ PriceListLine: Record "Price List Line";
+ SalesHeader: Record "Sales Header";
+ SalesLine: Record "Sales Line";
+ begin
+ // [SCENARIO] Customer with a specific price list line gets that unit price
+ // [GIVEN] a customer and an item with a customer-specific sales price list line
+ LibrarySales.CreateCustomer(Customer);
+ LibraryInventory.CreateItem(Item);
+ LibraryPriceCalculation.CreatePriceHeader(
+ PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
+ LibraryPriceCalculation.CreateSalesPriceLine(
+ PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
+ "Price Asset Type"::Item, Item."No.");
+ // CreatePriceHeader leaves the list in Draft status, which price calculation ignores.
+ PriceListHeader.Validate(Status, PriceListHeader.Status::Active);
+ PriceListHeader.Modify(true);
+ // [WHEN] a sales line is created for that customer and item
+ LibrarySales.CreateSalesDocumentWithItem(
+ SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
+ // [THEN] the sales line picks up the customer's price list line
+ Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", 'Unit price must match customer price list');
+ end;
+}
diff --git a/microsoft/knowledge/testing/test-feature-scenario-tags.md b/microsoft/knowledge/testing/test-feature-scenario-tags.md
new file mode 100644
index 0000000..89198fb
--- /dev/null
+++ b/microsoft/knowledge/testing/test-feature-scenario-tags.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [feature, scenario, given, when, then, tags, bdd, atdd, comments, subtype-test]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Tag test codeunits with FEATURE, SCENARIO, GIVEN, WHEN, and THEN comments
+
+## Description
+
+Test codeunits are easier to trust and to review when they carry a four-level comment structure taken from Behaviour-/Acceptance-Test-Driven Development: `[FEATURE]` once at the top of the codeunit naming the functional area under test, `[SCENARIO]` above each test procedure stating one falsifiable business claim in plain language, and `[GIVEN]`/`[WHEN]`/`[THEN]` marking the precondition, action, and assertion inside the test body. Without these tags a test procedure is an opaque block of AL that only reveals its intent by being read line by line; a reviewer or product owner cannot scan a codeunit and know what business behaviour it covers.
+
+## Best Practice
+
+Put `[FEATURE]` as a comment before the codeunit's opening brace, naming the domain rather than the object β Microsoft's own guidance allows setting it once for the whole codeunit, inherited by every test in it. Put `[SCENARIO]`, matching the current BCApps corpus, as the first comment inside each test procedure's body (after `begin`), describing the scenario in business language that complements β not duplicates β the procedure name, followed by `[GIVEN]` marking the precondition setup, `[WHEN]` marking the single action under test, and `[THEN]` marking the assertions. The procedure name stays the machine-readable identity shown in test-runner output; the `[SCENARIO]` comment stays the human-readable one. Neither replaces the other.
+
+See sample: [`test-feature-scenario-tags.good.al`](test-feature-scenario-tags.good.al).
+
+## Anti Pattern
+
+A test procedure with no `[FEATURE]`/`[SCENARIO]`/`[GIVEN]`/`[WHEN]`/`[THEN]` structure, setup mixed freely with assertions, and a procedure name like `Test1` that says nothing about what is being verified. Nothing in the codeunit tells a reader what business rule it exists to protect.
+
+See sample: [`test-feature-scenario-tags.bad.al`](test-feature-scenario-tags.bad.al).
diff --git a/microsoft/knowledge/testing/test-one-when-per-test.bad.al b/microsoft/knowledge/testing/test-one-when-per-test.bad.al
new file mode 100644
index 0000000..c49696c
--- /dev/null
+++ b/microsoft/knowledge/testing/test-one-when-per-test.bad.al
@@ -0,0 +1,32 @@
+[Test]
+procedure GetPrice_ThenGetPriceLines_ReturnsCorrectValues()
+var
+ Customer: Record Customer;
+ Item: Record Item;
+ PriceListHeader: Record "Price List Header";
+ PriceListLine: Record "Price List Line";
+ SalesHeader: Record "Sales Header";
+ SalesLine: Record "Sales Line";
+begin
+ // [GIVEN] ...
+ LibrarySales.CreateCustomer(Customer);
+ LibraryInventory.CreateItem(Item);
+ LibraryPriceCalculation.CreatePriceHeader(
+ PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
+ LibraryPriceCalculation.CreateSalesPriceLine(
+ PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
+ "Price Asset Type"::Item, Item."No.");
+ // [WHEN] first action
+ LibrarySales.CreateSalesDocumentWithItem(
+ SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
+ // [WHEN] second action β this is a second test in disguise
+ PriceListLine.Validate("Minimum Quantity", 10);
+ PriceListLine.Modify(true);
+ LibraryPriceCalculation.CreateSalesPriceLine(
+ PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
+ "Price Asset Type"::Item, Item."No.");
+ // [THEN] asserting two unrelated things
+ Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", '');
+ PriceListLine.SetRange("Price List Code", PriceListHeader.Code);
+ Assert.AreEqual(2, PriceListLine.Count(), '');
+end;
diff --git a/microsoft/knowledge/testing/test-one-when-per-test.good.al b/microsoft/knowledge/testing/test-one-when-per-test.good.al
new file mode 100644
index 0000000..6b58c51
--- /dev/null
+++ b/microsoft/knowledge/testing/test-one-when-per-test.good.al
@@ -0,0 +1,56 @@
+[Test]
+procedure GetPrice_CustomerPrice_ReturnsCorrectUnitPrice()
+var
+ Customer: Record Customer;
+ Item: Record Item;
+ PriceListHeader: Record "Price List Header";
+ PriceListLine: Record "Price List Line";
+ SalesHeader: Record "Sales Header";
+ SalesLine: Record "Sales Line";
+begin
+ // [GIVEN] a customer with a price list line for the item
+ LibrarySales.CreateCustomer(Customer);
+ LibraryInventory.CreateItem(Item);
+ LibraryPriceCalculation.CreatePriceHeader(
+ PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
+ LibraryPriceCalculation.CreateSalesPriceLine(
+ PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
+ "Price Asset Type"::Item, Item."No.");
+ // CreatePriceHeader leaves the list in Draft status, which price calculation ignores.
+ PriceListHeader.Validate(Status, PriceListHeader.Status::Active);
+ PriceListHeader.Modify(true);
+ // [WHEN]
+ LibrarySales.CreateSalesDocumentWithItem(
+ SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
+ // [THEN]
+ Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", 'Unit price must match price list');
+end;
+
+[Test]
+procedure GetPriceLines_TwoMinimumQuantityLines_ReturnsBoth()
+var
+ Customer: Record Customer;
+ Item: Record Item;
+ PriceListHeader: Record "Price List Header";
+ PriceListLine: Record "Price List Line";
+begin
+ // [GIVEN] a customer price list with two minimum-quantity price lines for the same item
+ LibrarySales.CreateCustomer(Customer);
+ LibraryInventory.CreateItem(Item);
+ LibraryPriceCalculation.CreatePriceHeader(
+ PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
+ LibraryPriceCalculation.CreateSalesPriceLine(
+ PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
+ "Price Asset Type"::Item, Item."No.");
+ PriceListLine.Validate("Minimum Quantity", 10);
+ PriceListLine.Modify(true);
+ LibraryPriceCalculation.CreateSalesPriceLine(
+ PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
+ "Price Asset Type"::Item, Item."No.");
+ PriceListLine.Validate("Minimum Quantity", 50);
+ PriceListLine.Modify(true);
+ // [WHEN]
+ PriceListLine.SetRange("Price List Code", PriceListHeader.Code);
+ // [THEN]
+ Assert.AreEqual(2, PriceListLine.Count(), 'Exactly two price lines expected');
+end;
diff --git a/microsoft/knowledge/testing/test-one-when-per-test.md b/microsoft/knowledge/testing/test-one-when-per-test.md
new file mode 100644
index 0000000..69c3b37
--- /dev/null
+++ b/microsoft/knowledge/testing/test-one-when-per-test.md
@@ -0,0 +1,34 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [when, single-action, bdd, atdd, given-when-then, flow-test, regression-test]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Keep exactly one WHEN per test, with narrow exceptions for flow and defect-then-fix tests
+
+## Description
+
+This is a testing-design practice, not a BC platform requirement β no AL API enforces it, and it should not gate a change the way a platform-contradicted claim would. Each test procedure should contain exactly one `[WHEN]` block: one action that triggers the behaviour under test. A test with multiple WHENs β "do A, then do B, then check C" β is two or more tests in disguise. Splitting them gives failure isolation (a failing test points at one action, not an ambiguous sequence) and keeps each test readable as a single, falsifiable claim. A precondition action, such as posting a document so a ledger entry exists to assert against, belongs in `[GIVEN]`; only the action actually being asserted belongs in `[WHEN]`.
+
+## Best Practice
+
+Give each test one `[WHEN]` and one focused claim. A procedure name containing "And" or "Then" in the middle (`GetPrice_AndDiscount_ReturnsValues`) is a strong signal the test should be split.
+
+See sample: [`test-one-when-per-test.good.al`](test-one-when-per-test.good.al).
+
+## Anti Pattern
+
+A test that performs a first action, then a second unrelated action, then asserts on both β mixing two falsifiable claims into one procedure so a failure can't tell you which action broke.
+
+See sample: [`test-one-when-per-test.bad.al`](test-one-when-per-test.bad.al).
+
+## Flow tests β a deliberate exception
+
+A flow test verifies the accumulated outcome of a genuinely multi-round business process (partial receipt then invoicing, several posting rounds against one document), where the sequence itself is the scenario β splitting it would lose the interaction under test. Multiple `[WHEN]` blocks are allowed only when the procedure name declares the flow, each `[WHEN]` is labelled as one round of a single scenario rather than an unrelated action, and the `[THEN]` asserts the accumulated end-state rather than assertions that decompose cleanly per action (if they do decompose cleanly, it is still two tests in disguise). Outside this shape, unit-level tests keep the strict one-WHEN rule.
+
+## Defect-then-fix tests β a second, narrower exception
+
+A test that reproduces a specific broken state and then verifies a subsequent action corrects it is not the same shape as an unrelated-action test, even though its `[THEN]` assertions decompose cleanly per step β clean decomposition is expected here, not a sign of two unrelated tests. This shape is permitted only when the second `[WHEN]` cannot be meaningfully tested without the first (the fix only affects the exact stale state the first action produced, so splitting would just re-run the first action inside a second test's `[GIVEN]`), and the procedure name communicates the before/after relationship.
diff --git a/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md
index 03d8854..f296d51 100644
--- a/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md
+++ b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md
@@ -17,10 +17,10 @@ application-area: [all]
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`.
+See sample: [`testisolation-belongs-on-the-test-runner.good.al`](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`.
+See sample: [`testisolation-belongs-on-the-test-runner.bad.al`](testisolation-belongs-on-the-test-runner.bad.al).
diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md
index ab89a96..0f4f3bd 100644
--- a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md
+++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md
@@ -11,16 +11,20 @@ application-area: [all]
## Description
-`[TransactionModel(...)]` declares how a test method interacts with the database's write transaction. The attribute applies only to methods inside a codeunit with `SubType = Test` and takes one of three values: `AutoRollback`, `AutoCommit`, or `None`. The choice must match the code being exercised β in particular, whether that code calls `Commit()`. Per the platform reference, "if the code that you test includes calls to the COMMIT Method, then set the TransactionModel property on the test method to AutoCommit." Applying `AutoRollback` to a test that drives code which calls `Commit` produces a runtime error on the first Commit, not a meaningful assertion failure β the test does not complete, and the reviewer sees an infrastructure error instead of a business-logic verdict.
+`[TransactionModel(...)]` declares how a test method interacts with the database's write transaction. The attribute applies only to methods inside a codeunit with `SubType = Test` and takes one of three values: `AutoRollback`, `AutoCommit`, or `None`. **`AutoCommit` is the documented default** β a test method with no `[TransactionModel(...)]` attribute at all runs under `AutoCommit`, not `AutoRollback` and not `None` (Microsoft's TransactionModel property reference states this explicitly: "AutoCommit is the default value"). The "a call to `Commit` produces a runtime error" behavior is specific to the *explicitly declared* `AutoRollback` attribute. BCApps' own canonical pattern for a lazily-initialized shared fixture (see `codeunit 134915 "ERM Online Mapping Setup"`) declares no `TransactionModel` attribute at all β so it runs under the `AutoCommit` default β calls `Commit()` inside its `Initialize()` helper, and cleans up manually with a deliberate `asserterror Error(...)` at the end rather than relying on automatic rollback; this is a legitimate, common pattern, not a bug. Per the same reference, under `AutoCommit` an error, even one caught by `asserterror`, still rolls back the transaction β but "only to the point at which `Commit` was called" if the code being tested committed first. When a test method *does* declare `AutoRollback` explicitly, the choice must match the code being exercised: per the platform reference, "if the code that you test includes calls to the COMMIT Method, then set the TransactionModel property on the test method to AutoCommit." Applying `AutoRollback` to a test that drives code which calls `Commit` produces a runtime error on the first Commit, not a meaningful assertion failure.
## 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 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.
+Leave `[TransactionModel(...)]` undeclared to get the `AutoCommit` default when the codeunit's own tests rely on that default's behavior β for example a lazily-initialized shared fixture that commits once and cleans up its own scratch changes with a manual `asserterror`-based rollback (see `commit-shared-test-fixture-inside-lazy-initialize.md`); do not treat that absence as equivalent to declaring `AutoRollback`. When declaring `[TransactionModel(...)]` explicitly instead, pick `AutoRollback` for a test whose own logic and the code it exercises make no `Commit` call, `AutoCommit` when the code under test genuinely calls `Commit` β posting routines, job-queue handlers, integration flows β and make the test exercise that commit path, and `None` for a read-only test or one that drives UI code without writing from the test method itself. Pair an intentional, suite-wide reliance on `AutoCommit` with a `TestIsolation`-enabled test runner so committed changes are reverted at a higher scope.
-See sample: `transactionmodel-attribute-governs-test-transactions.good.al`.
+See sample: [`transactionmodel-attribute-governs-test-transactions.good.al`](transactionmodel-attribute-governs-test-transactions.good.al).
## Anti Pattern
-Applying `AutoRollback` to every test method without checking whether the tested business logic calls `Commit`. The test throws at the first Commit, leaving no verdict on the behavior it intended to verify; in a CI run this looks like a flake or a setup bug, not a specification mismatch. The mirror-image anti-pattern is defaulting to `AutoCommit` across the suite "to avoid the error" β without a `TestIsolation` runner this permanently dirties the test database between runs and produces order-dependent test outcomes.
+Declaring `[TransactionModel(AutoRollback)]` explicitly on a test method without checking whether the tested business logic calls `Commit`. The test throws at the first Commit, leaving no verdict on the behavior it intended to verify; in a CI run this looks like a flake or a setup bug, not a specification mismatch. The mirror-image anti-pattern is defaulting to `AutoCommit` across the suite "to avoid the error" β without a `TestIsolation` runner this permanently dirties the test database between runs and produces order-dependent test outcomes. Flagging a `Commit()` call in a test method that declares no `TransactionModel` attribute at all is not this anti-pattern β that shape does not error, and is BCApps' own documented pattern for shared lazy fixtures.
-See sample: `transactionmodel-attribute-governs-test-transactions.bad.al`.
+See sample: [`transactionmodel-attribute-governs-test-transactions.bad.al`](transactionmodel-attribute-governs-test-transactions.bad.al).
+
+## Source
+
+The `AutoCommit`-is-default claim and the exact rollback-to-last-`Commit` mechanics are quoted from Microsoft's TransactionModel Property reference: https://learn.microsoft.com/en-us/previous-versions/dynamicsnav-2018-developer/TransactionModel-Property. The current AL [TransactionModel attribute](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/attributes/devenv-transactionmodel-attribute) page describes the same three values but never states a default; this older property reference is the citable source for that fact.
diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.md b/microsoft/knowledge/testing/ui-handlers-in-tests.md
index d451301..9ca1ce1 100644
--- a/microsoft/knowledge/testing/ui-handlers-in-tests.md
+++ b/microsoft/knowledge/testing/ui-handlers-in-tests.md
@@ -21,10 +21,10 @@ Beyond that wiring guarantee, the test must verify the behavior it cares about.
List the handlers the scenario triggers, keep an optional notification handler listed for a notification the scenario may conditionally raise, and make each executed handler contribute meaningful evidence. For a single modal page, reset a capture variable before the action, capture a concrete value from the page in the handler, and assert the expected value after `RunModal`. For ordered or repeated interactions, let the test enqueue expectations, let handlers dequeue and verify them, clear storage during initialization, and finish with `AssertEmpty`.
-See sample: `ui-handlers-in-tests.good.al`.
+See sample: [`ui-handlers-in-tests.good.al`](ui-handlers-in-tests.good.al).
## Anti Pattern
Omitting a handler for a UI call, listing a nonoptional handler the path never reaches, or claiming action success from a Boolean set before the action runs. A handler that only closes a page can also leave the test without a semantic assertion. Do not flag the absence of queue storage by itself; require it only when the test needs to prove interaction order, count, text, replies, or a scripted sequence. Do not flag a listed `[SendNotificationHandler(true)]` or `[RecallNotificationHandler(true)]` that the run does not reach, and never propose removing one: the entry is what keeps the test passing on the runs where the notification does fire.
-See sample: `ui-handlers-in-tests.bad.al`.
+See sample: [`ui-handlers-in-tests.bad.al`](ui-handlers-in-tests.bad.al).
diff --git a/microsoft/knowledge/testing/ui-test-codeunit-naming.bad.al b/microsoft/knowledge/testing/ui-test-codeunit-naming.bad.al
new file mode 100644
index 0000000..2acba18
--- /dev/null
+++ b/microsoft/knowledge/testing/ui-test-codeunit-naming.bad.al
@@ -0,0 +1,27 @@
+codeunit 50104 "Item Price Testing"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure ApplyDiscount_LogicTest()
+ var
+ Assert: Codeunit "Library Assert";
+ begin
+ // logic test β fine on its own, but not paired with a UI test below
+ Assert.AreEqual(90, ApplyDiscount(100, 10), 'A 10% discount on 100 must yield 90');
+ end;
+
+ local procedure ApplyDiscount(UnitPrice: Decimal; DiscountPct: Decimal): Decimal
+ begin
+ exit(UnitPrice - (UnitPrice * DiscountPct / 100));
+ end;
+
+ [Test]
+ procedure CustomerCard_Opens_UT()
+ var
+ CustomerCard: TestPage "Customer Card";
+ begin
+ // UI test mixed into a logic-test codeunit, and the codeunit lacks the _UT suffix
+ CustomerCard.OpenNew();
+ end;
+}
diff --git a/microsoft/knowledge/testing/ui-test-codeunit-naming.good.al b/microsoft/knowledge/testing/ui-test-codeunit-naming.good.al
new file mode 100644
index 0000000..6c343d8
--- /dev/null
+++ b/microsoft/knowledge/testing/ui-test-codeunit-naming.good.al
@@ -0,0 +1,42 @@
+codeunit 50105 "Item Price Testing"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure ApplyDiscount_ReducesUnitPrice()
+ var
+ Assert: Codeunit "Library Assert";
+ DiscountedPrice: Decimal;
+ begin
+ DiscountedPrice := ApplyDiscount(100, 10);
+ Assert.AreEqual(90, DiscountedPrice, 'A 10% discount on 100 must yield 90');
+ end;
+
+ local procedure ApplyDiscount(UnitPrice: Decimal; DiscountPct: Decimal): Decimal
+ begin
+ exit(UnitPrice - (UnitPrice * DiscountPct / 100));
+ end;
+}
+
+codeunit 50106 "Item Price Testing_UT"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure CustomerCard_SetName_UpdatesField()
+ var
+ Customer: Record Customer;
+ CustomerCard: TestPage "Customer Card";
+ Assert: Codeunit "Library Assert";
+ LibrarySales: Codeunit "Library - Sales";
+ begin
+ LibrarySales.CreateCustomer(Customer);
+ CustomerCard.OpenEdit();
+ CustomerCard.GoToRecord(Customer);
+ CustomerCard.Name.SetValue('Updated Name');
+ CustomerCard.Close();
+
+ Customer.Get(Customer."No.");
+ Assert.AreEqual('Updated Name', Customer.Name, 'Name must be updated through the page');
+ end;
+}
diff --git a/microsoft/knowledge/testing/ui-test-codeunit-naming.md b/microsoft/knowledge/testing/ui-test-codeunit-naming.md
new file mode 100644
index 0000000..0790f37
--- /dev/null
+++ b/microsoft/knowledge/testing/ui-test-codeunit-naming.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [ui-test, testpage, naming, suffix, codeunit, page-testing, team-convention]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Separate UI-layer and logic-layer tests into different codeunits
+
+## Description
+
+A test codeunit that drives pages through `TestPage` β opening pages, reading FactBox parts, triggering field `OnValidate` through the page β is testing a different layer than a codeunit that calls business-logic procedures directly. Readers need to know which layer a given test exercises without opening it, and a single codeunit that mixes both kinds of test hides that distinction: a failure could mean the logic broke, the page broke, or both. The `_UT` suffix and adjacent-object-ID pairing below are one team's naming convention for making that split visible, not a BCApps-wide naming standard β BCApps itself uses `UT` for unit tests generally, not specifically to mean "UI layer," and does not treat adjacent object IDs as a semantic pairing mechanism. Apply the suffix only on a project that has explicitly adopted this convention.
+
+## Best Practice
+
+Keep UI-layer (`TestPage`-driven) and logic-layer tests in separate codeunits regardless of naming. Projects that adopt a `_UT`-style suffix convention should apply it consistently to every UI-layer test codeunit, keep the corresponding logic-only codeunit unsuffixed, and document the convention where the team's other naming rules live.
+
+See sample: [`ui-test-codeunit-naming.good.al`](ui-test-codeunit-naming.good.al).
+
+## Anti Pattern
+
+One codeunit that mixes a direct logic-call test and a `TestPage`-driven test side by side β a failing test no longer tells a reader which layer actually broke. On a project that has adopted the `_UT` convention, a UI-layer codeunit missing the suffix is also an instance of this anti-pattern; on a project that has not adopted it, the suffix itself is not required.
+
+See sample: [`ui-test-codeunit-naming.bad.al`](ui-test-codeunit-naming.bad.al).
diff --git a/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.bad.al b/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.bad.al
new file mode 100644
index 0000000..07ad6b2
--- /dev/null
+++ b/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.bad.al
@@ -0,0 +1,18 @@
+codeunit 50143 "Sample Doc Amount Test"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure DocAmountIsNotVerifiedWhenLinesAreMissing()
+ var
+ Assert: Codeunit Assert;
+ PurchHeader: Record "Purchase Header";
+ begin
+ asserterror Assert.IsTrue(VerifyDocAmount(PurchHeader), 'Doc. amount should not verify with no lines.');
+ end;
+
+ local procedure VerifyDocAmount(var PurchHeader: Record "Purchase Header"): Boolean
+ begin
+ exit(false);
+ end;
+}
diff --git a/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.good.al b/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.good.al
new file mode 100644
index 0000000..eb21d6c
--- /dev/null
+++ b/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.good.al
@@ -0,0 +1,18 @@
+codeunit 50143 "Sample Doc Amount Test"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure DocAmountIsNotVerifiedWhenLinesAreMissing()
+ var
+ Assert: Codeunit Assert;
+ PurchHeader: Record "Purchase Header";
+ begin
+ Assert.IsFalse(VerifyDocAmount(PurchHeader), 'Doc. amount should not verify with no lines.');
+ end;
+
+ local procedure VerifyDocAmount(var PurchHeader: Record "Purchase Header"): Boolean
+ begin
+ exit(false);
+ end;
+}
diff --git a/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.md b/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.md
new file mode 100644
index 0000000..182a62b
--- /dev/null
+++ b/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.md
@@ -0,0 +1,34 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [assert, isfalse, istrue, asserterror, boolean-check, negative-test]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Use Assert.IsFalse to check a boolean result, not asserterror around Assert.IsTrue
+
+## Description
+
+`asserterror` exists to assert that a statement raises a runtime error; it is not a general-purpose way to invert a boolean check. Wrapping `asserterror Assert.IsTrue(SomeFunc(), Msg)` to verify that `SomeFunc()` returns `false` tests whether `Assert.IsTrue`'s own error-raising behavior fired, not the value `SomeFunc()` actually returned.
+
+## Best Practice
+
+When the code under test returns a `Boolean` rather than raising an error, assert the value directly with `Assert.IsFalse(SomeFunc(), Msg)` (or `Assert.IsTrue` for the positive case). Reserve `asserterror` for statements expected to actually raise an error.
+
+See sample: [`use-assert-isfalse-not-asserterror-for-boolean-checks.good.al`](use-assert-isfalse-not-asserterror-for-boolean-checks.good.al).
+
+## Anti Pattern
+
+`asserterror Assert.IsTrue(SomeFunc(), Msg);` to verify `SomeFunc()` is `false`. It passes today because `Assert.IsTrue` happens to raise an error on failure, but it verifies the assertion helper's error-raising behavior, not the value under test.
+
+See sample: [`use-assert-isfalse-not-asserterror-for-boolean-checks.bad.al`](use-assert-isfalse-not-asserterror-for-boolean-checks.bad.al).
+
+## Source
+
+Drawn from Luc van Vugt's "TDD in NAV β ASSERTERROR or IsFalse": https://www.fluxxus.nl/index.php/bc/tdd-in-nav-asserterror-or-isfalse/. The post's own example and reasoning β reserve `asserterror` for the product code actually raising an error, use `Assert.IsFalse`/`Assert.IsTrue` to check a boolean the test framework itself computes β carries over directly; the overlap with `asserterror-needs-expectederror-and-code.md` below is this repository's own addition, not from the source.
+
+## Scope
+
+This rule and `asserterror-needs-expectederror-and-code.md` can both match `asserterror Assert.IsTrue(SomeFunc(), Msg);` with nothing after it β the generic rule sees a bare `asserterror`, this one sees `asserterror` wrapping an `Assert.IsTrue`/`Assert.IsFalse` call used to invert a boolean. This rule wins for that shape: the fix is to replace the construct with a direct `Assert.IsFalse`/`Assert.IsTrue` call, not to add `Assert.ExpectedError`/`Assert.ExpectedErrorCode` after it. `asserterror-needs-expectederror-and-code.md` still applies on its own to every other bare `asserterror`, including one guarding `Assert.IsTrue`/`Assert.IsFalse` where the intent genuinely is to assert that the guarded call itself raises an error (for example, asserting that a validation helper errors before it can even return a boolean).
diff --git a/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.bad.al b/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.bad.al
new file mode 100644
index 0000000..c718bc7
--- /dev/null
+++ b/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.bad.al
@@ -0,0 +1,10 @@
+codeunit 50132 "Sample Customer Type Library"
+{
+ procedure CreateCustomerType(var CustomerType: Record "Customer Type")
+ begin
+ CustomerType.Init();
+ CustomerType.Code := 'TEST001';
+ CustomerType.Description := 'Test Customer Type';
+ CustomerType.Insert(true);
+ end;
+}
diff --git a/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.good.al b/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.good.al
new file mode 100644
index 0000000..25a194e
--- /dev/null
+++ b/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.good.al
@@ -0,0 +1,21 @@
+codeunit 50132 "Sample Customer Type Library"
+{
+ var
+ LibraryUtility: Codeunit "Library - Utility";
+
+ procedure CreateCustomerType(var CustomerType: Record "Customer Type")
+ begin
+ CustomerType.Init();
+ // Code is shorter than GenerateGUID()'s 10 characters, and this field's
+ // uniqueness matters, so use GenerateRandomCodeWithLength: it opens the
+ // real (non-temporary) table and loops until the value doesn't collide.
+ // GenerateRandomCode would not do this β it opens the table as temporary,
+ // so its own emptiness check never inspects real rows.
+ CustomerType.Code :=
+ LibraryUtility.GenerateRandomCodeWithLength(CustomerType.FieldNo(Code), Database::"Customer Type", MaxStrLen(CustomerType.Code));
+ // Description is long enough to hold the full GenerateGUID() value
+ // untruncated, and only needs to be incidental, not verified-unique.
+ CustomerType.Description := CopyStr(LibraryUtility.GenerateGUID(), 1, MaxStrLen(CustomerType.Description));
+ CustomerType.Insert(true);
+ end;
+}
diff --git a/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.md b/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.md
new file mode 100644
index 0000000..e06f738
--- /dev/null
+++ b/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.md
@@ -0,0 +1,33 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [generateguid, library-utility, test-fixtures, uniqueness, generaterandomcode, maxstrlen]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Generate unique test fixture values with LibraryUtility helpers, not hardcoded literals
+
+## Description
+
+A fixture helper that assigns a hardcoded literal to a primary-key field, or to any field the test relies on as a unique lookup identifier, collides the moment two tests, or two runs of the same test, create that fixture without cleanup β and a literal longer than the field allows raises a truncation or insert error. An ordinary descriptive field carries no such constraint: two rows with the same description do not collide on insert, and a deterministic descriptive value is often exactly what an exact-match assertion needs, so none of this applies to it. `LibraryUtility.GenerateGUID()` is not a real GUID β it is a `Code[10]` number-series value (`GU00000000`β`GU99999999`) β and it returns the full 10 characters unshortened. Truncating it yourself with `CopyStr(..., 1, MaxStrLen(ShorterField))` for a field under 10 characters is unsafe: the changing digits sit at the right end and are exactly what gets cut off, so consecutive calls into a short field can produce the same truncated value. `GenerateGUID()` is only safe as-is for a field that holds the full 10 characters.
+
+## Best Practice
+
+For a field that holds the full 10 characters, assign `LibraryUtility.GenerateGUID()` directly. For a shorter field, do not truncate a GUID yourself β but also do not assume every `LibraryUtility` helper verifies uniqueness against the real table, because they don't all behave the same way:
+
+- `GenerateRandomCode(FieldNo, TableNo)` opens the target table as a **temporary** `RecordRef`: the buffer starts and stays empty, so its `repeat...until RecRef.IsEmpty()` loop always exits after one iteration β despite taking `TableNo`, it never checks the real table, and it never retries even within its own call. Its value is the rightmost `FieldRef.Length` characters of `GenerateGUID()`'s sequential `GU00000000`β`GU99999999` series, so for a short field that window of digits cycles: a 1-character field repeats every 10 calls, a 2-character field every 100, and so on. It is a finite short-field namespace with a low collision *chance* within one test run β not a guarantee at any scope, unlike the table-checking helpers below.
+- `GenerateRandomCodeWithLength(FieldNo, TableNo, CodeLength)` opens the real (non-temporary) table and loops until the generated value doesn't collide β a genuine verified-unique guarantee β but it returns `Code[10]` regardless of the requested `CodeLength`, so it's only useful for a field of 10 characters or fewer.
+- `GenerateRandomCode20(FieldNo, TableNo)` is the same real, verified-against-the-table pattern as `GenerateRandomCodeWithLength`, sized for a `Code[20]` field.
+- `GenerateRandomXMLText(Length)` performs no table lookup at all β it's a plain random-text generator, appropriate for a descriptive/incidental field where uniqueness doesn't matter, not for a value that needs to be collision-checked.
+
+Pick `GenerateRandomCodeWithLength`/`GenerateRandomCode20` when the test genuinely needs a code verified unique against the table; use `GenerateRandomCode`/`GenerateGUID`/`GenerateRandomXMLText` for incidental values where a low collision *chance* is enough.
+
+See sample: [`use-generateguid-for-unique-test-fixture-values.good.al`](use-generateguid-for-unique-test-fixture-values.good.al).
+
+## Anti Pattern
+
+Hardcoding a primary-key or unique-lookup fixture value such as `'TEST001'`, which collides across parallel or repeated test runs β a fixed descriptive value is not this anti-pattern, since the field carries no uniqueness constraint. Equally an anti-pattern: truncating `GenerateGUID()`'s result with `CopyStr(..., 1, MaxStrLen(Field))` for a field shorter than 10 characters β the truncation removes the part of the value that actually varies.
+
+See sample: [`use-generateguid-for-unique-test-fixture-values.bad.al`](use-generateguid-for-unique-test-fixture-values.bad.al).
diff --git a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md
index 270a146..9c8f092 100644
--- a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md
+++ b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md
@@ -17,10 +17,10 @@ BC ships a layer of test Library codeunits β `LibrarySales`, `LibraryPurchase`
Reach for the matching Library codeunit before writing manual record setup: `LibrarySales.CreateCustomer`, `LibrarySales.CreateSalesHeader`/`CreateSalesLine`, `LibraryInventory.CreateItem`, `LibraryERM.CreateGLAccount`, and `LibraryRandom.RandInt`/`RandDec` for values. Create the prerequisite parents first and reference their primary keys from dependent records, and `Validate` the foreign-key field so the `TableRelation` β and any field-validation logic β runs exactly as it would in production. Pass the records they return into the code under test. The fixtures stay valid across upgrades because the library β not your test β owns the knowledge of what a well-formed record requires.
-See sample: `use-library-codeunits-for-test-fixtures.good.al`.
+See sample: [`use-library-codeunits-for-test-fixtures.good.al`](use-library-codeunits-for-test-fixtures.good.al).
## Anti Pattern
`Customer.Init(); Customer."No." := 'X'; Customer.Insert();` β a record with a hand-picked primary key, no number-series entry, and none of the mandatory fields a real customer needs. It compiles and may even insert, but it bypasses setup the production code assumes, and it breaks the first time the schema gains a required field the test does not know about.
-See sample: `use-library-codeunits-for-test-fixtures.bad.al`.
+See sample: [`use-library-codeunits-for-test-fixtures.bad.al`](use-library-codeunits-for-test-fixtures.bad.al).
diff --git a/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.bad.al b/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.bad.al
new file mode 100644
index 0000000..ddaf0ff
--- /dev/null
+++ b/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.bad.al
@@ -0,0 +1,27 @@
+codeunit 50134 "Sample Customer Type Edit Test"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure CustomerTypeFieldNotEditable_WhenLocked()
+ var
+ Assert: Codeunit Assert;
+ CustomerType: Record "Customer Type";
+ CustomerTypeCard: TestPage "Customer Type Card";
+ begin
+ // [GIVEN] a customer type record whose Locked flag is set
+ CustomerType.Init();
+ CustomerType.Locked := true;
+ CustomerType.Insert(true);
+
+ // [WHEN] the page is opened in VIEW mode β editability logic that only
+ // applies in edit mode is not exercised the same way
+ CustomerTypeCard.OpenView();
+ CustomerTypeCard.GoToRecord(CustomerType);
+
+ // [THEN] wrong function: Enabled() does not verify editability
+ Assert.IsFalse(CustomerTypeCard.Description.Enabled(), 'Description should not be editable while Locked is set.');
+
+ CustomerTypeCard.Close();
+ end;
+}
diff --git a/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.good.al b/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.good.al
new file mode 100644
index 0000000..d683f0b
--- /dev/null
+++ b/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.good.al
@@ -0,0 +1,26 @@
+codeunit 50133 "Sample Customer Type Edit Test"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure CustomerTypeFieldNotEditable_WhenLocked()
+ var
+ Assert: Codeunit Assert;
+ CustomerType: Record "Customer Type";
+ CustomerTypeCard: TestPage "Customer Type Card";
+ begin
+ // [GIVEN] a customer type record whose Locked flag is set
+ CustomerType.Init();
+ CustomerType.Locked := true;
+ CustomerType.Insert(true);
+
+ // [WHEN] the page is opened in edit mode on that record
+ CustomerTypeCard.OpenEdit();
+ CustomerTypeCard.GoToRecord(CustomerType);
+
+ // [THEN] the field's actual editable state reflects the lock
+ Assert.IsFalse(CustomerTypeCard.Description.Editable(), 'Description should not be editable while Locked is set.');
+
+ CustomerTypeCard.Close();
+ end;
+}
diff --git a/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.md b/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.md
new file mode 100644
index 0000000..f94c792
--- /dev/null
+++ b/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [testpage, editable, openedit, ui-state, field-verification]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Verify field editability with TestPage.Editable(), opened in edit mode
+
+## Description
+
+Whether a field can actually be changed is a distinct state from whether it is shown or enabled β `Editable()` and `Enabled()` are separate `TestField` functions. Verifying editability also requires opening the `TestPage` with `OpenEdit()`, not `OpenView()`: `OpenView()` opens the page in view mode, so it does not exercise the field's own conditional editability logic the way an actual edit-mode session does.
+
+## Best Practice
+
+Open the `TestPage` with `OpenEdit()`, navigate to the relevant record, then assert against `TestPageField.Editable()` to verify whether the field can be changed under the given precondition.
+
+See sample: [`use-testpage-editable-to-verify-field-editability.good.al`](use-testpage-editable-to-verify-field-editability.good.al).
+
+## Anti Pattern
+
+Asserting `Enabled()` (or checking nothing at all) when the actual claim is about editability, or opening the page with `OpenView()` when the field's editability depends on business logic that only applies in edit mode.
+
+See sample: [`use-testpage-editable-to-verify-field-editability.bad.al`](use-testpage-editable-to-verify-field-editability.bad.al).
diff --git a/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.bad.al b/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.bad.al
new file mode 100644
index 0000000..410af39
--- /dev/null
+++ b/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.bad.al
@@ -0,0 +1,14 @@
+codeunit 50131 "Sample Customer Type UI Test"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure CustomerTypeFieldIsEnabledOnCustomerCard()
+ var
+ CustomerCard: TestPage "Customer Card";
+ begin
+ // Confirms only that the page opens - never checks the field's actual UI state
+ CustomerCard.OpenView();
+ CustomerCard.Close();
+ end;
+}
diff --git a/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.good.al b/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.good.al
new file mode 100644
index 0000000..6e03c06
--- /dev/null
+++ b/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.good.al
@@ -0,0 +1,15 @@
+codeunit 50131 "Sample Customer Type UI Test"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure CustomerTypeFieldIsEnabledOnCustomerCard()
+ var
+ Assert: Codeunit Assert;
+ CustomerCard: TestPage "Customer Card";
+ begin
+ CustomerCard.OpenView();
+ Assert.IsTrue(CustomerCard."Customer Type".Enabled(), 'Customer Type should be enabled on the Customer Card.');
+ Assert.IsTrue(CustomerCard."Customer Type".Visible(), 'Customer Type should be visible on the Customer Card.');
+ end;
+}
diff --git a/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.md b/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.md
new file mode 100644
index 0000000..ff6bd86
--- /dev/null
+++ b/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [testpage, visible, enabled, ui-state, headless-test, field-verification]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Verify field visibility and enabled state with TestPage.Visible()/.Enabled()
+
+## Description
+
+A UI test codeunit does not need to inspect table or page properties indirectly to confirm a field is shown or enabled under given conditions. The `TestPage` object exposes a `Visible()` and an `Enabled()` function on each field, reflecting the page's actual rendered state, callable directly from a `[Test]` procedure. `Enabled()` and `Editable()` are distinct states β this article covers visibility/enabled state specifically; see `use-testpage-editable-to-verify-field-editability.md` for verifying whether a field can actually be changed.
+
+## Best Practice
+
+Open the `TestPage`, navigate to the relevant record if needed, then assert against `TestPageField.Visible()` and `TestPageField.Enabled()` to verify the field's shown/enabled state, rather than checking an unrelated table/page property or skipping the check.
+
+See sample: [`use-testpage-visible-enabled-to-verify-field-ui-state.good.al`](use-testpage-visible-enabled-to-verify-field-ui-state.good.al).
+
+## Anti Pattern
+
+A test that opens the `TestPage` but never asserts against `Visible()`/`Enabled()` on the field in question β confirming only that the page opens, not that the field behaves as expected.
+
+See sample: [`use-testpage-visible-enabled-to-verify-field-ui-state.bad.al`](use-testpage-visible-enabled-to-verify-field-ui-state.bad.al).
diff --git a/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md b/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md
index 87b539b..00dc9b8 100644
--- a/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md
+++ b/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [24..]
domain: ui
keywords: [tooltip, page-field, source-field, inheritance, aa0218, false-positive]
technologies: [al]
@@ -11,14 +11,20 @@ application-area: [all]
## Description
-A page field bound to a table field inherits the source field's `ToolTip` at runtime: the control shows the table field's `ToolTip` even when the page control declares none of its own. A page field without an inline `ToolTip` is therefore not, by itself, a missing-tooltip defect β the text may be supplied by the bound source field.
+Starting with BC24 (2024 release wave 1), runtime 13.0 supports `ToolTip` on table fields. A page field bound to a table field inherits the source field's `ToolTip` when the page control declares none of its own. A page field without an inline `ToolTip` is therefore not, by itself, a missing-tooltip defect. This inheritance is not available when targeting earlier runtimes.
-The genuinely-missing case is different: a bound field whose source table field *also* carries no `ToolTip`, or an unbound control, has no text to inherit and is a real accessibility gap. The compiler analyzer AA0218 detects this mechanically, but its severity is set by each app's ruleset and is routinely downgraded or disabled β so it cannot be relied on as the only net. PR review is the last line of defence and should raise this case independently.
+The genuinely-missing case is different: a control with no inline `ToolTip` also has no text to inherit when it is unbound or its source table field carries no non-empty `ToolTip`. This leaves a real user-assistance gap. The compiler analyzer AA0218 detects this mechanically, but its severity is set by each app's ruleset and may be downgraded or disabled, so review should raise the genuine gap independently.
## Best Practice
-Do not raise a missing-`ToolTip` finding for a bound page field whose source table field supplies a `ToolTip`; assume the control inherits it. Do raise a `medium`-severity finding when the field has no inline `ToolTip` **and** no inherited one β that is, a bound field whose source field is also tooltip-less, or an unbound control β rather than assuming AA0218 will catch it downstream.
+Check the target runtime and inspect the source field, including dependency symbols when needed. On runtime 13.0 or later, do not raise a missing-`ToolTip` finding for a bound page field whose source table field supplies a non-empty `ToolTip`, and do not add a duplicate page-level property. A page-level override is appropriate only when the page needs different help text or no tooltip can be inherited.
+
+Do raise a `medium`-severity finding when the field has no inline `ToolTip` **and** no inherited one, rather than assuming AA0218 will catch it downstream. If the source definition is unavailable, do not infer that its tooltip is missing. See [tooltip requirements across target versions](../style/tooltip-required-on-page-fields.md).
## Anti Pattern
Two opposite failures: (1) flagging every page field that has no inline `ToolTip` as a violation, ignoring that a bound field inherits its source field's tooltip; and (2) staying silent on a field that has neither an inline nor an inherited tooltip on the assumption that the compiler's AA0218 will report it β a ruleset that downgrades or disables AA0218 then lets a genuine gap ship unflagged.
+
+## References
+
+[ToolTip property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-tooltip-property).
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
index f0d3f8c..648a138 100644
--- a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md
+++ b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md
@@ -19,7 +19,7 @@ Noun-phrase captions (object names, field labels such as `'Source Document No.'`
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`.
+See sample: [`caption-capitalization-noun-phrase-vs-sentence-phrase.good.al`](caption-capitalization-noun-phrase-vs-sentence-phrase.good.al).
## Anti Pattern
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
index d5edd39..f0b5314 100644
--- a/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md
+++ b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md
@@ -17,13 +17,13 @@ JavaScript in a Business Central control add-in can load a static resource from
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`.
+See sample: [`control-addin-package-resource-ajax-needs-withcredentials.good.js`](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`.
+See sample: [`control-addin-package-resource-ajax-needs-withcredentials.bad.js`](control-addin-package-resource-ajax-needs-withcredentials.bad.js).
## Source
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
index 987eedb..92d29b5 100644
--- 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
@@ -17,13 +17,13 @@ application-area: [all]
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`.
+See sample: [`control-addin-throttle-al-calls-and-payload-size.good.js`](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`.
+See sample: [`control-addin-throttle-al-calls-and-payload-size.bad.js`](control-addin-throttle-al-calls-and-payload-size.bad.js).
## Source
diff --git a/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.md b/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.md
index 185e56f..a3607c3 100644
--- a/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.md
+++ b/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.md
@@ -15,9 +15,9 @@ Historical list pages should default to showing the newest records first. On pag
## Best Practice
Set descending sort as the default on list pages whose primary purpose is to present historical records. This is the expected default for entry, log, archive, and posted-history pages unless there is a specific requirement to begin with the oldest record.
-See sample: `default-descending-sort-on-historical-pages.good.al`.
+See sample: [`default-descending-sort-on-historical-pages.good.al`](default-descending-sort-on-historical-pages.good.al).
## Anti Pattern
Using an oldest-first default order on a historical list page where users are primarily interested in recent activity. Typical signs include history, log, or entry pages that regularly need to be re-sorted to descending during normal use.
-See sample: `default-descending-sort-on-historical-pages.bad.al`.
\ No newline at end of file
+See sample: [`default-descending-sort-on-historical-pages.bad.al`](default-descending-sort-on-historical-pages.bad.al).
\ No newline at end of file
diff --git a/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.bad.al b/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.bad.al
new file mode 100644
index 0000000..78602b4
--- /dev/null
+++ b/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.bad.al
@@ -0,0 +1,9 @@
+tableextension 50622 "Ship-to Dropdown Bad" extends "Ship-to Address"
+{
+ fieldgroups
+ {
+ addlast(DropDown; "Address 2")
+ {
+ }
+ }
+}
diff --git a/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.good.al b/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.good.al
new file mode 100644
index 0000000..c8a8c28
--- /dev/null
+++ b/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.good.al
@@ -0,0 +1,20 @@
+tableextension 50620 "Ship-to Dropdown Good" extends "Ship-to Address"
+{
+ fieldgroups
+ {
+ addlast(DropDown; "Address 2")
+ {
+ }
+ }
+}
+
+pageextension 50621 "Ship-to Lookup Good" extends "Ship-to Address List"
+{
+ layout
+ {
+ modify("Address 2")
+ {
+ Visible = true;
+ }
+ }
+}
diff --git a/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.md b/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.md
new file mode 100644
index 0000000..afa2c34
--- /dev/null
+++ b/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [fieldgroup, dropdown, addlast, lookup-page, visible, tableextension, pageextension]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# A `DropDown` field remains hidden when its lookup-page control is hidden
+
+## Description
+
+A tableextension can append a field to the `DropDown` field group with `addlast`, but the client still omits that field when its control on the underlying lookup page has `Visible = false`. Changing only the table field group therefore compiles while producing no visible UI change. The field-group name is case-sensitive and must be written as `DropDown`.
+
+## Best Practice
+
+When adding a hidden field to a `DropDown` field group, also extend the page used for the lookup and make that field control visible. Verify the actual lookup page rather than assuming the table definition alone controls the drop-down.
+
+See sample: [`dropdown-fieldgroup-respects-lookup-page-visibility.good.al`](dropdown-fieldgroup-respects-lookup-page-visibility.good.al).
+
+## Anti Pattern
+
+Adding the field with `addlast(DropDown; ...)` while leaving its lookup-page control hidden, then expecting the field to appear in the drop-down.
+
+See sample: [`dropdown-fieldgroup-respects-lookup-page-visibility.bad.al`](dropdown-fieldgroup-respects-lookup-page-visibility.bad.al).
+
+## Reference
+
+[Add a new FieldGroup to an existing table](https://learn.microsoft.com/en-us/training/modules/extend-modify-existing-table/add-field-group)
diff --git a/microsoft/knowledge/ui/grid-data-table-heuristic.md b/microsoft/knowledge/ui/grid-data-table-heuristic.md
index 2cd6b63..1f82a52 100644
--- a/microsoft/knowledge/ui/grid-data-table-heuristic.md
+++ b/microsoft/knowledge/ui/grid-data-table-heuristic.md
@@ -25,4 +25,4 @@ Any grid or fixed layout that does not meet all three conditions renders as a la
If you intend a grid or fixed layout to render as a data table, satisfy all three conditions and verify the resulting markup matches your intent. If you do not need tabular semantics, prefer simple groups over grid or fixed layouts β they reflow better and produce correct semantic markup automatically.
-See sample: `grid-data-table-heuristic.good.al`.
+See sample: [`grid-data-table-heuristic.good.al`](grid-data-table-heuristic.good.al).
diff --git a/microsoft/knowledge/ui/group-labeled-first-child-exception.md b/microsoft/knowledge/ui/group-labeled-first-child-exception.md
index 2f47ae3..1acdb36 100644
--- a/microsoft/knowledge/ui/group-labeled-first-child-exception.md
+++ b/microsoft/knowledge/ui/group-labeled-first-child-exception.md
@@ -23,10 +23,10 @@ When these three conditions hold, the group caption becomes the accessible label
Do not second-guess this exception. If the three conditions are met, the pattern is acceptable β even if the group caption seems generic (e.g. "General Information") or does not exactly match the field name.
-See sample: `group-labeled-first-child-exception.good.al`.
+See sample: [`group-labeled-first-child-exception.good.al`](group-labeled-first-child-exception.good.al).
## Anti Pattern
If the parent group has `ShowCaption = false` or no `Caption`, the first-child exception does not apply: the field has no accessible label anywhere.
-See sample: `group-labeled-first-child-exception.bad.al`.
+See sample: [`group-labeled-first-child-exception.bad.al`](group-labeled-first-child-exception.bad.al).
diff --git a/microsoft/knowledge/ui/no-nested-grids.md b/microsoft/knowledge/ui/no-nested-grids.md
index 9de7ba4..d678809 100644
--- a/microsoft/knowledge/ui/no-nested-grids.md
+++ b/microsoft/knowledge/ui/no-nested-grids.md
@@ -19,4 +19,4 @@ Always flag a nested grid as a violation. The fix is to restructure the page so
Wrapping a working data-table grid inside another grid in an attempt to compose two tabular regions side by side. The outer grid silently degrades to layout-table rendering, the inner grid's headers are no longer associated with the outer structure, and editable fields with `ShowCaption = false` lose their labels.
-See sample: `no-nested-grids.bad.al`.
+See sample: [`no-nested-grids.bad.al`](no-nested-grids.bad.al).
diff --git a/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.bad.al b/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.bad.al
new file mode 100644
index 0000000..cb537ca
--- /dev/null
+++ b/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.bad.al
@@ -0,0 +1,59 @@
+enum 50700 "Sample Request Status"
+{
+ Extensible = false;
+
+ value(0; New) { Caption = 'New'; }
+ value(1; "Needs Review") { Caption = 'Needs Review'; }
+ value(2; Approved) { Caption = 'Approved'; }
+}
+
+table 50700 "Sample Request"
+{
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20]) { }
+ field(2; Status; Enum "Sample Request Status") { }
+ }
+
+ keys
+ {
+ key(PK; "No.") { Clustered = true; }
+ }
+}
+
+page 50700 "Sample Request Card"
+{
+ PageType = Card;
+ SourceTable = "Sample Request";
+ ApplicationArea = All;
+
+ layout
+ {
+ area(Content)
+ {
+ field("No."; Rec."No.") { }
+ field(Status; Rec.Status) { }
+ }
+ }
+
+ actions
+ {
+ area(Processing)
+ {
+ action(Approve)
+ {
+ Caption = 'Approve';
+ // AL0573: InListExpression is not valid for client expressions.
+ Enabled = Rec.Status in [Rec.Status::New, Rec.Status::"Needs Review"];
+
+ trigger OnAction()
+ begin
+ Rec.Status := Rec.Status::Approved;
+ Rec.Modify(true);
+ end;
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.good.al b/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.good.al
new file mode 100644
index 0000000..f36ea5a
--- /dev/null
+++ b/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.good.al
@@ -0,0 +1,83 @@
+enum 50700 "Sample Request Status"
+{
+ Extensible = false;
+
+ value(0; New) { Caption = 'New'; }
+ value(1; "Needs Review") { Caption = 'Needs Review'; }
+ value(2; Approved) { Caption = 'Approved'; }
+}
+
+table 50700 "Sample Request"
+{
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20]) { }
+ field(2; Status; Enum "Sample Request Status") { }
+ }
+
+ keys
+ {
+ key(PK; "No.") { Clustered = true; }
+ }
+}
+
+page 50700 "Sample Request Card"
+{
+ PageType = Card;
+ SourceTable = "Sample Request";
+ ApplicationArea = All;
+
+ layout
+ {
+ area(Content)
+ {
+ field("No."; Rec."No.")
+ {
+ // A plain field comparison is a valid client expression.
+ Editable = Rec.Status = Rec.Status::New;
+ }
+ field(Status; Rec.Status)
+ {
+ trigger OnValidate()
+ begin
+ UpdateActionStates();
+ end;
+ }
+ }
+ }
+
+ actions
+ {
+ area(Processing)
+ {
+ action(Approve)
+ {
+ Caption = 'Approve';
+ // The list membership is computed in AL and exposed as a global Boolean.
+ Enabled = ApproveEnabled;
+
+ trigger OnAction()
+ begin
+ Rec.Status := Rec.Status::Approved;
+ Rec.Modify(true);
+ UpdateActionStates();
+ end;
+ }
+ }
+ }
+
+ var
+ ApproveEnabled: Boolean;
+
+ trigger OnAfterGetCurrRecord()
+ begin
+ UpdateActionStates();
+ end;
+
+ local procedure UpdateActionStates()
+ begin
+ ApproveEnabled := Rec.Status in [Rec.Status::New, Rec.Status::"Needs Review"];
+ end;
+}
diff --git a/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.md b/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.md
new file mode 100644
index 0000000..60941cb
--- /dev/null
+++ b/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.md
@@ -0,0 +1,38 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [client-expression, in-list, inlistexpression, al0573, al0322, enabled, visible, editable, dynamic-enable]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Page client-expression properties must not use an `in [...]` list
+
+## Description
+
+`Enabled`, `Visible`, `Editable`, and `StyleExpr` on page controls (the list is not exhaustive; `HideValue` is rejected the same way) can be bound to a client expression instead of a literal. The documented dynamic forms are a global Boolean page variable, a Boolean field, or a Boolean expression over fields such as `"Credit Limit" > "Sales YTD"`; plain `=`/`<>`/`>` comparisons combined with `and`/`or`/`not` are valid. An `in [...]` set-membership test, such as `Rec.Status in [Rec.Status::New, Rec.Status::"Needs Review"]`, is not: the compiler reports it as "InListExpression is not valid for client expressions. Client expressions can only use simple data types and field references."
+
+The severity depends on the control. On a page field the diagnostic is already an error (AL0322). On an action, group, or part it is AL0573, a warning that "will become an error in a future release", so the code still builds and is easy to ship, suppress in a ruleset, or carry forward. A procedure call in the same property position is rejected by the same diagnostics, so moving the list test into a method called from the property does not fix it.
+
+## Best Practice
+
+Express the condition in a form a client expression accepts. For a short list, rewrite the membership as an `or` chain of field comparisons, which keeps the property a live client expression. For a longer or computed condition, evaluate it in AL (an `in [...]` list is fine there), store the result in a global page `Boolean` variable, and bind the property to that variable. Recompute the variable wherever its inputs change: `OnAfterGetCurrRecord` when the current record changes, and the `OnValidate` of each page field the condition reads for in-place edits. Do not use `OnAfterGetRecord` for action, group, or part state: it runs once per row loaded, so on a List or Worksheet page the variable ends up reflecting the last fetched row rather than the selected one (see [OnAfterGetCurrRecord is not per row](../performance/onaftergetcurrrecord-is-not-per-row.md)). `OnAfterGetRecord` is right only for per-row field state inside a repeater.
+
+For `Visible` on field and action controls, the Visible property documentation requires the variable to be resolved in `OnInit` or `OnOpenPage`; do not rely on per-record recomputation to show and hide those controls. `Enabled` and `Editable` have no such restriction. See sample: [`page-client-expression-must-not-use-in-list.good.al`](page-client-expression-must-not-use-in-list.good.al).
+
+## Anti Pattern
+
+A page or pageextension control property `Enabled`, `Visible`, `Editable`, or `StyleExpr` whose value contains `in [`, typically an enum or option field tested against several values. Reviewer signal: the `in [` token appears directly in the property value rather than inside a trigger or procedure body. Replacing it with a call to a procedure that performs the same test is the same defect in a different shape.
+
+Do not flag plain comparisons joined with `and`/`or`, such as `Enabled = (Rec.Status = Rec.Status::New) or (Rec.Status = Rec.Status::"Needs Review");`; they compile cleanly and Microsoft uses them, for example in BCApps `AccountantExpenseReports.Page.al` and `AgentTaskLogEntry.Page.al`. Do not flag `in [...]` used inside procedures or triggers that assign a Boolean variable. See sample: [`page-client-expression-must-not-use-in-list.bad.al`](page-client-expression-must-not-use-in-list.bad.al).
+
+## References
+
+- [Enabled property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-enabled-property): dynamic values are a Boolean variable, a Boolean field, or a Boolean expression such as "Credit Limit > Sales YTD"; variables must be global page variables.
+- [OnAfterGetCurrRecord (Page) trigger](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/page/devenv-onaftergetcurrrecord-page-trigger): in a page with a repeater, called only when the current record is updated, after all `OnAfterGetRecord` calls for the rows.
+- [Visible property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-visible-property): variables for field and action controls must be resolved by `OnInit` or `OnOpenPage`.
+- [Compiler warning AL0573](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al573) and [compiler error AL0322](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al322). The Learn pages show only the `{0}` template; the compiler's message for this case is "InListExpression is not valid for client expressions. Client expressions can only use simple data types and field references." (AL compiler 30.0: AL0573 for action, group, and part properties; AL0322 for page field properties, including `HideValue`).
+- Comparison-based client expressions in BCApps, for example `Enabled = Rec.Status <> Rec.Status::Running;` in [BCPTSetupCard.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Tools/Performance%20Toolkit/App/src/BCPTSetupCard.Page.al). BCApps contains no page client expression that uses an `in [...]` list.
+- Global Boolean recomputed on current-record change in BCApps: [EDocumentLogs.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/EDocument/App/src/Logging/EDocumentLogs.Page.al), a List page, binds `Enabled = IsExportEnabled;` and sets `IsExportEnabled` in `OnAfterGetCurrRecord`.
+- Or-chain client expressions in BCApps: `Enabled = (Rec.Status = Rec.Status::"Pending Approval") or (Rec.Status = Rec.Status::"Interim Approved");` in [AccountantExpenseReports.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/AccountantExpenseReports.Page.al) and `Visible = (Rec.Type = Rec.Type::"Output Message Draft") or (Rec.Type = Rec.Type::"Output Message");` in [AgentTaskLogEntry.Page.al](https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Agent/Troubleshooting/AgentTaskLogEntry.Page.al).
diff --git a/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.bad.al b/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.bad.al
new file mode 100644
index 0000000..fd371f5
--- /dev/null
+++ b/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.bad.al
@@ -0,0 +1,21 @@
+page 50131 "Sample Item List"
+{
+ PageType = List;
+ SourceTable = "Sample Item";
+ // Anti-pattern: no CardPageID even though a Card page exists for
+ // this table, and no UsageCategory, so the page is invisible to
+ // Tell Me search.
+ ApplicationArea = All;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(Group)
+ {
+ field(Description; Rec.Description) { }
+ field("No."; Rec."No.") { } // primary key buried, not left-most
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.good.al b/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.good.al
new file mode 100644
index 0000000..7b53ac0
--- /dev/null
+++ b/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.good.al
@@ -0,0 +1,20 @@
+page 50130 "Sample Item List"
+{
+ PageType = List;
+ SourceTable = "Sample Item";
+ CardPageID = "Sample Item Card"; // links back to its Card page
+ UsageCategory = Lists;
+ ApplicationArea = All;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(Group)
+ {
+ field("No."; Rec."No.") { } // primary key, left-most
+ field(Description; Rec.Description) { }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.md b/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.md
new file mode 100644
index 0000000..18f7178
--- /dev/null
+++ b/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.md
@@ -0,0 +1,90 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [pages, page-design, naming-conventions, page-type, card-page, list-page, factbox, worksheet-page, document-page, rolecenter, cardpageid, autosplitkey]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Pages must match one of Business Central's page-type conventions
+
+## Description
+
+Business Central's page types β RoleCenter, Card, List, CardPart,
+ListPart, Worksheet, Document, ListPlus, plus system dialog/special
+types such as `NavigatePage`, `ConfirmationDialog`, `StandardDialog`,
+`HeadlinePart`, and `API` (a selected list of conventional types this
+article covers design conventions for β not an exhaustive catalogue of
+every current `PageType` value; `PromptDialog`, `ConfigurationDialog`,
+`UserControlHost`, and `XmlPort` also exist but follow their own
+design rules, out of scope here) β each
+fix a naming pattern and a structural constraint, not just a visual
+layout. A page whose name, primary-key handling, or linkage
+(`CardPageID`, `SubPageLink`, `AutoSplitKey`) doesn't match its own type's
+conventions is either the wrong page type for the job or built
+inconsistently with the rest of the application, and should be flagged in
+review even if it compiles and renders. Before naming a new page or
+wiring its links, first ask which page type it is, and whether the source
+table actually fits that type's structural requirement β the type fixes
+the naming suffix, which fields are visible, and which other page it must
+link back to.
+
+## Best Practice
+
+Match the page's design to its type:
+
+- **RoleCenter** β tailored home page for a role; named role + `Role
+ Center`; links to List pages, shows Cues/Activities.
+- **Card** β view/edit one record; named table + `Card`; FastTabs only,
+ first FastTab named `General`. A single-field primary key is typical,
+ but not a hard requirement: a subsidiary table that supplements a
+ master record with its own identity (parent key + own code β Ship-to
+ Address, Customer/Vendor Bank Account) commonly gets its own Card page
+ over a composite key too. Treat the key shape as a contextual signal,
+ not a mandatory constraint β a composite-key table with no such
+ supplementing relationship to a master record is the actual signal a
+ List/Worksheet/Tabular page fits better.
+- **List** β view multiple records, also the lookup/drilldown surface;
+ named table + `List` if read-only, or the plural table name if
+ editable; primary-key fields shown left-most; `CardPageID` must point
+ at the associated Card page when one exists.
+- **CardPart** β single-column FactBox; named for its content +
+ `FactBox`.
+- **ListPart** β multi-column FactBox or subpage (e.g. document lines);
+ named for its content + `FactBox`/`SubPage`; `SubPageLink` must
+ actually filter to the host record.
+- **Worksheet** β multi-record entry for a Journal-like table, insertion
+ order preserved; primary-key fields never shown; uses `AutoSplitKey`
+ with a trailing `Integer` key field.
+- **Document** β FastTabs plus a lines subpage, lines filtered to the
+ header; named for the document (`Sales Invoice`).
+- **ListPlus** β like Document but with multiple lists instead of one;
+ named like the record/report it summarizes.
+- System dialog types (`NavigatePage`, `ConfirmationDialog`,
+ `StandardDialog`, `HeadlinePart`) are fixed shapes with no page-name
+ suffix convention. `API` pages follow their own property rules and are
+ extended by adding a new API page, never a page extension.
+
+Before wiring controls, the design step should fix: which users and
+tasks the page serves, the concrete fields/commands/links those tasks
+need, the page type that matches the content (chosen before the source
+table), and the source table that actually holds the page's primary data.
+
+See sample: [`page-design-must-match-bc-page-type-conventions.good.al`](page-design-must-match-bc-page-type-conventions.good.al).
+
+## Anti Pattern
+
+A page that mixes conventions from two types β for example, a "List"
+page with no `CardPageID` even though a Card page exists for the same
+table β signals a design step was skipped, not a stylistic choice. A
+Card page over a composite-key table is not automatically this anti
+pattern; check whether the table supplements a master record first. Also watch
+for a Worksheet or List page showing primary-key fields it shouldn't (or
+hiding them when it should show them). A page with no `UsageCategory` set
+is not automatically a defect either: supporting pages, subpages, dialogs,
+and pages intended only to be reached through another workflow correctly
+have no `UsageCategory` β flag its absence only on a page intended as a
+searchable entry point in its own right.
+
+See sample: [`page-design-must-match-bc-page-type-conventions.bad.al`](page-design-must-match-bc-page-type-conventions.bad.al).
diff --git a/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.bad.al b/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.bad.al
new file mode 100644
index 0000000..c9f9a8b
--- /dev/null
+++ b/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.bad.al
@@ -0,0 +1,23 @@
+pageextension 50710 "Sample Bus. Mgr. RC Ext" extends "Business Manager Role Center"
+{
+ layout
+ {
+ addafter(Control16)
+ {
+ part(SampleMyCustomers; "My Customers")
+ {
+ ApplicationArea = Basic, Suite;
+ // AL0573: procedure calls are not valid for client expressions.
+ Visible = CanSeeMyCustomers();
+ }
+ }
+ }
+
+ // AL0569: a page of type Role Center cannot have procedures.
+ local procedure CanSeeMyCustomers(): Boolean
+ var
+ Customer: Record Customer;
+ begin
+ exit(Customer.ReadPermission());
+ end;
+}
diff --git a/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.good.al b/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.good.al
new file mode 100644
index 0000000..c054d0a
--- /dev/null
+++ b/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.good.al
@@ -0,0 +1,16 @@
+pageextension 50710 "Sample Bus. Mgr. RC Ext" extends "Business Manager Role Center"
+{
+ layout
+ {
+ addafter(Control16)
+ {
+ part(SampleMyCustomers; "My Customers")
+ {
+ ApplicationArea = Basic, Suite;
+ // Declarative permission gating: the part is removed for users
+ // without Read permission on Customer.
+ AccessByPermission = TableData Customer = R;
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.md b/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.md
new file mode 100644
index 0000000..e3cd058
--- /dev/null
+++ b/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.md
@@ -0,0 +1,32 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [accessbypermission, rolecenter, role-center, pageextension, client-expression, permission, al0569, al0573, al0378]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Gate Role Center content by permission with AccessByPermission
+
+## Description
+
+A page of type `RoleCenter` cannot have triggers (AL0378, error) or procedures (AL0569, warning that will become an error), and the AL compiler applies both rules to a pageextension whose target is a Role Center. That removes the usual way to show a control conditionally: compute a global Boolean in `OnOpenPage` and bind `Visible` to it. An easy-looking remediation of AL0378 is to delete the trigger and bind `Visible` or `Enabled` directly to a local procedure such as `CanSeeMyCustomers()`. That still builds, but with two future errors: AL0573 for the procedure call in a client expression and AL0569 for the procedure itself. Neither message names the alternative.
+
+When the condition is "the user has permission to this object", the declarative alternative is the `AccessByPermission` property on the part, action, or field. It takes `TableData = R|I|M|D` (any combination; having any one of the listed permissions is enough) or `X` for `Table`, `Page`, `Report`, `Codeunit`, `XmlPort`, or `Query`. Its applies-to list covers page fields, parts, system parts, chart parts, actions, and whole pages and reports; it does not include groups or cue groups. The element is removed for users without the permission, not disabled. That per-user removal requires the UI Elements Removal setting `LicenseFileAndUserPermissions`; under `LicenseFile` removal follows the license, not the user's permission sets.
+
+## Best Practice
+
+Set `AccessByPermission` on the Role Center part, action, or field that should appear only for users with the permission, naming the table or object that the part actually depends on. The base application does this on its own Role Centers, for example `AccessByPermission = TableData "Activities Cue" = I` on the activities part of the Business Manager Role Center and `TableData "Report Inbox" = IMD` on the Report Inbox part (`Control96`) of the same Role Center and of the Accountant Role Center.
+
+Two limits apply. The property takes effect only when the server's UI Elements Removal setting is `LicenseFile` or `LicenseFileAndUserPermissions`. It is UI removal, not a security boundary: the part's source data must still be protected by real permissions. When the condition is not a permission check, such as a setup value or a feature flag, `AccessByPermission` is the wrong tool. Put the condition inside the part page, which is a normal `CardPart` or `ListPart` that can have triggers. The part cannot remove itself from the Role Center, but it can hide or empty its own controls. See sample: [`rolecenter-permission-gating-must-use-accessbypermission.good.al`](rolecenter-permission-gating-must-use-accessbypermission.good.al).
+
+## Anti Pattern
+
+A `RoleCenter` page, or a pageextension whose target is a Role Center, binds `Visible` or `Enabled` on a part, action, or field to a procedure call whose body checks `ReadPermission`, `WritePermission`, or a similar permission test, and declares that procedure. The compiler reports AL0573 and AL0569 as warnings, and both will become errors. Reviewer signal: a pageextension declares a procedure and AL0569 appears in its build output. The extended page's name is not reliable evidence of its type, so confirm the target is a Role Center from its `PageType` or from that diagnostic. Do not flag setup- or feature-based gating implemented inside the part page itself; that is the correct location for non-permission conditions. See sample: [`rolecenter-permission-gating-must-use-accessbypermission.bad.al`](rolecenter-permission-gating-must-use-accessbypermission.bad.al).
+
+## References
+
+- [AccessByPermission property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-accessbypermission-property): applies-to list, permission values, any-one-of semantics, and the UI Elements Removal requirement ([Hide UI elements](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/administration/hide-ui-elements)).
+- [Compiler error AL0378](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al378), [compiler warning AL0569](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al569), and [compiler warning AL0573](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al573). AL compiler 30.0 reports all three on a pageextension of a Role Center, as it does on the Role Center page itself.
+- Base application usage: [BusinessManagerRoleCenter.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Finance/RoleCenters/BusinessManagerRoleCenter.Page.al) (`Control96`, lines 124-127) and [AccountantRoleCenter.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Finance/RoleCenters/AccountantRoleCenter.Page.al). No Role Center page in BCApps declares a trigger or procedure.
diff --git a/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md
index 5f26333..0fac910 100644
--- a/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md
+++ b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md
@@ -19,4 +19,4 @@ This is a narrow platform exception to `semantic-styles-need-independent-textual
You may apply `Favorable`, `Unfavorable`, or `Ambiguous` to fields inside a `cuegroup` without supplying a redundant textual indicator β the platform supplies the screen-reader text. Reserve this shortcut for cue tiles only; do not extend it to other layout containers.
-See sample: `semantic-style-in-cuegroup-exception.good.al`.
+See sample: [`semantic-style-in-cuegroup-exception.good.al`](semantic-style-in-cuegroup-exception.good.al).
diff --git a/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md
index 9d58d41..de03248 100644
--- a/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md
+++ b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md
@@ -27,8 +27,8 @@ The rule applies equally whether `Style` is set to a literal value or to a varia
## Best Practice
-When you reach for `Favorable`, `Unfavorable`, or `Ambiguous`, verify that the caption, value, or an adjacent column already conveys the same meaning. See sample: `semantic-styles-need-independent-textual-meaning.good.al`.
+When you reach for `Favorable`, `Unfavorable`, or `Ambiguous`, verify that the caption, value, or an adjacent column already conveys the same meaning. See sample: [`semantic-styles-need-independent-textual-meaning.good.al`](semantic-styles-need-independent-textual-meaning.good.al).
## Anti Pattern
-Applying a semantic style for purely cosmetic emphasis (e.g. green company name for aesthetics), or using semantic colors where only the color reveals the threshold (e.g. confidence percentages with no qualitative label). See sample: `semantic-styles-need-independent-textual-meaning.bad.al`.
+Applying a semantic style for purely cosmetic emphasis (e.g. green company name for aesthetics), or using semantic colors where only the color reveals the threshold (e.g. confidence percentages with no qualitative label). See sample: [`semantic-styles-need-independent-textual-meaning.bad.al`](semantic-styles-need-independent-textual-meaning.bad.al).
diff --git a/microsoft/knowledge/ui/set-selection-filter-list-scope.md b/microsoft/knowledge/ui/set-selection-filter-list-scope.md
index 513590c..8d0fc45 100644
--- a/microsoft/knowledge/ui/set-selection-filter-list-scope.md
+++ b/microsoft/knowledge/ui/set-selection-filter-list-scope.md
@@ -17,11 +17,11 @@ The base platform avoids this ambiguity by routing batch list actions through Re
## Best Practice
-After calling `SetSelectionFilter`, test `MarkedOnly`. When it is false β meaning the user made no explicit selection, or selected all rows with Ctrl+A β discard the single-row primary key filter by copying the page source record (`Copy(Rec)`), which carries the full page view including all active filter groups. When `MarkedOnly` is true the user made a deliberate selection and that filter should be respected as-is. Refer to `set-selection-filter-list-scope.good.al` for the pattern.
+After calling `SetSelectionFilter`, test `MarkedOnly`. When it is false β meaning the user made no explicit selection, or selected all rows with Ctrl+A β discard the single-row primary key filter by copying the page source record (`Copy(Rec)`), which carries the full page view including all active filter groups. When `MarkedOnly` is true the user made a deliberate selection and that filter should be respected as-is. Refer to [`set-selection-filter-list-scope.good.al`](set-selection-filter-list-scope.good.al) for the pattern.
## Anti Pattern
-Passing the result of `SetSelectionFilter` directly to a processing codeunit without checking `MarkedOnly`. When the user runs the action with the cursor on row three and no rows highlighted, the codeunit receives a filter that matches only row three. The action appears to succeed but processes a fraction of the intended scope. The defect is hard to notice because no error is raised and the single-row run completes without complaint. See `set-selection-filter-list-scope.bad.al`.
+Passing the result of `SetSelectionFilter` directly to a processing codeunit without checking `MarkedOnly`. When the user runs the action with the cursor on row three and no rows highlighted, the codeunit receives a filter that matches only row three. The action appears to succeed but processes a fraction of the intended scope. The defect is hard to notice because no error is raised and the single-row run completes without complaint. See [`set-selection-filter-list-scope.bad.al`](set-selection-filter-list-scope.bad.al).
## See also
diff --git a/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md
index dcf4d95..2b830bc 100644
--- a/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md
+++ b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md
@@ -19,4 +19,4 @@ This exception does **not** extend to dynamically editable fields. A field with
If you want to hide a field's caption, pair `ShowCaption = false` with a literal `Editable = false`. Use this pattern only for content fields that do not act as labels for other fields in the same layout container.
-See sample: `show-caption-false-allowed-on-non-editable-fields.good.al`.
+See sample: [`show-caption-false-allowed-on-non-editable-fields.good.al`](show-caption-false-allowed-on-non-editable-fields.good.al).
diff --git a/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md
index 9b2e43b..b170205 100644
--- a/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md
+++ b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md
@@ -19,4 +19,4 @@ Fields in the `area(Content)` section of the same PromptDialog page are **not**
In a PromptDialog, give the page a meaningful `Caption` (the dialog heading) and let prompt-area input fields hide their own captions. Treat content-area fields like any other editable field β keep their captions.
-See sample: `show-caption-in-promptdialog-prompt-area.good.al`.
+See sample: [`show-caption-in-promptdialog-prompt-area.good.al`](show-caption-in-promptdialog-prompt-area.good.al).
diff --git a/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md
index b161149..f81762f 100644
--- a/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md
+++ b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md
@@ -19,4 +19,4 @@ This is the explicit behaviour of the Business Central client: a repeater render
Inside a repeater, you may set `ShowCaption = false` on fields without losing accessibility. The column header still provides the label for every cell in that column. Outside a repeater, the rules in `show-caption-on-editable-fields.md` apply.
-See sample: `show-caption-in-repeater-allowed.good.al`.
+See sample: [`show-caption-in-repeater-allowed.good.al`](show-caption-in-repeater-allowed.good.al).
diff --git a/microsoft/knowledge/ui/show-caption-on-editable-fields.md b/microsoft/knowledge/ui/show-caption-on-editable-fields.md
index 23075fd..604ace0 100644
--- a/microsoft/knowledge/ui/show-caption-on-editable-fields.md
+++ b/microsoft/knowledge/ui/show-caption-on-editable-fields.md
@@ -19,10 +19,10 @@ A field whose `Editable` property is a Boolean expression (e.g. `Editable = IsEd
Leave `ShowCaption` at its default on editable fields. If a caption would be visually redundant, rely on one of the documented magic patterns (group-labeled first child, repeater column, PromptDialog prompt input) rather than removing the caption.
-See sample: `show-caption-on-editable-fields.good.al`.
+See sample: [`show-caption-on-editable-fields.good.al`](show-caption-on-editable-fields.good.al).
## Anti Pattern
The `InstructionalText` property on a field renders as HTML placeholder text and is **not** a substitute for a caption β it disappears once the user types and is not reliably announced by screen readers.
-See sample: `show-caption-on-editable-fields.bad.al`.
+See sample: [`show-caption-on-editable-fields.bad.al`](show-caption-on-editable-fields.bad.al).
diff --git a/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.md b/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.md
index 91fc65e..eeffc07 100644
--- a/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.md
+++ b/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.md
@@ -17,11 +17,11 @@ application-area: [all]
## Best Practice
-Set `ShowMandatory = true` on every visible, editable page field whose value the user must supply before the record can be committed or an action can complete, and leave the enforcement in place: the property is presentation, `TestField`/`Error` is the guarantee, and the two belong together in the same change. When the requirement is conditional, bind `ShowMandatory` to a Boolean variable or field that mirrors the condition the enforcement checks β the base application drives `Vendor Invoice No.` on the Purchase Invoice page from an `Ext. Doc. No. Mandatory` setup flag this way. Two expression limits are worth knowing: the property cannot call an AL method, so compute the value into a variable first, and a numeric field that has a default value counts as filled, so it never shows the asterisk. See sample: `showmandatory-on-code-required-page-fields.good.al`.
+Set `ShowMandatory = true` on every visible, editable page field whose value the user must supply before the record can be committed or an action can complete, and leave the enforcement in place: the property is presentation, `TestField`/`Error` is the guarantee, and the two belong together in the same change. When the requirement is conditional, bind `ShowMandatory` to a Boolean variable or field that mirrors the condition the enforcement checks β the base application drives `Vendor Invoice No.` on the Purchase Invoice page from an `Ext. Doc. No. Mandatory` setup flag this way. Two expression limits are worth knowing: the property cannot call an AL method, so compute the value into a variable first, and a numeric field that has a default value counts as filled, so it never shows the asterisk. See sample: [`showmandatory-on-code-required-page-fields.good.al`](showmandatory-on-code-required-page-fields.good.al).
## Anti Pattern
-A required field with no mandatory marker: the table's `OnInsert` or the page's `OnInsertRecord` calls `TestField` on a field, or `NotBlank` is expected to force entry, while the page field bound to it carries no `ShowMandatory`. On a `DelayedInsert = true` list page the user fills the row, leaves it, and gets an error naming a field that never looked different from the optional ones. Reviewer signal: code on the relevant commit or action path requires the user to supply a field, the corresponding page control is visible and editable, and its `ShowMandatory` property is missing or does not mirror the same condition. A `TestField` or `Error` elsewhere in `OnValidate` or `OnModify` is not sufficient evidence: the field may be populated by code, non-editable, or required only for another path. Setting `ShowMandatory = false` on a field that is unconditionally required on the current path is the same defect stated explicitly, and per the documentation it also overrides any marking `NotBlank` would otherwise contribute. See sample: `showmandatory-on-code-required-page-fields.bad.al`.
+A required field with no mandatory marker: the table's `OnInsert` or the page's `OnInsertRecord` calls `TestField` on a field, or `NotBlank` is expected to force entry, while the page field bound to it carries no `ShowMandatory`. On a `DelayedInsert = true` list page the user fills the row, leaves it, and gets an error naming a field that never looked different from the optional ones. Reviewer signal: code on the relevant commit or action path requires the user to supply a field, the corresponding page control is visible and editable, and its `ShowMandatory` property is missing or does not mirror the same condition. A `TestField` or `Error` elsewhere in `OnValidate` or `OnModify` is not sufficient evidence: the field may be populated by code, non-editable, or required only for another path. Setting `ShowMandatory = false` on a field that is unconditionally required on the current path is the same defect stated explicitly, and per the documentation it also overrides any marking `NotBlank` would otherwise contribute. See sample: [`showmandatory-on-code-required-page-fields.bad.al`](showmandatory-on-code-required-page-fields.bad.al).
## See also
diff --git a/microsoft/knowledge/ui/standalone-content-in-layout-table.md b/microsoft/knowledge/ui/standalone-content-in-layout-table.md
index 74a69b2..63f8d58 100644
--- a/microsoft/knowledge/ui/standalone-content-in-layout-table.md
+++ b/microsoft/knowledge/ui/standalone-content-in-layout-table.md
@@ -19,4 +19,4 @@ Layout tables have no `| ` column headers, so a captionless field that is mean
Reserve `ShowCaption = false` in a layout-table grid for non-editable, free-standing content cells. If a field's role is to label or annotate another field in the same grid, restructure the grid to meet the data-table conditions (see `grid-data-table-heuristic.md`) instead of hiding the caption.
-See sample: `standalone-content-in-layout-table.good.al`.
+See sample: [`standalone-content-in-layout-table.good.al`](standalone-content-in-layout-table.good.al).
diff --git a/microsoft/knowledge/ui/style-expr-text-vs-boolean.md b/microsoft/knowledge/ui/style-expr-text-vs-boolean.md
index 5040099..720423b 100644
--- a/microsoft/knowledge/ui/style-expr-text-vs-boolean.md
+++ b/microsoft/knowledge/ui/style-expr-text-vs-boolean.md
@@ -22,4 +22,4 @@ When `StyleExpr` is Text, you must trace the variable's assignments β typicall
Inspect the declared type of the symbol referenced by `StyleExpr` before drawing conclusions. If it is Boolean, evaluate the `Style` property. If it is Text, follow every assignment to the variable and check the full set of possible style values against `cosmetic-styles-need-no-textual-context.md` and `semantic-styles-need-independent-textual-meaning.md`.
-See sample: `style-expr-text-vs-boolean.good.al`.
+See sample: [`style-expr-text-vs-boolean.good.al`](style-expr-text-vs-boolean.good.al).
diff --git a/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md
index b56aadb..c02bbd9 100644
--- a/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md
+++ b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md
@@ -24,4 +24,4 @@ Both manifestations have the same root cause: tabular semantics were intended bu
A single field that keeps its visible caption is enough to demote an entire would-be data-table grid into a layout table β and silently strip the labels off its sibling captionless fields. Either restructure to meet all three conditions, or restore captions on every editable field.
-See sample: `tabular-intent-requires-data-table-conditions.bad.al`.
+See sample: [`tabular-intent-requires-data-table-conditions.bad.al`](tabular-intent-requires-data-table-conditions.bad.al).
diff --git a/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.bad.al b/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.bad.al
new file mode 100644
index 0000000..f5d2b2b
--- /dev/null
+++ b/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.bad.al
@@ -0,0 +1,26 @@
+page 50631 "Sample Order Bad"
+{
+ PageType = Document;
+ SourceTable = "Sales Header";
+
+ layout
+ {
+ area(Content)
+ {
+ group(General)
+ {
+ field(Amount; Rec.Amount)
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies the total amount of the order.';
+ }
+ }
+ part(Lines; "Sales Order Subform")
+ {
+ ApplicationArea = All;
+ SubPageLink = "Document Type" = field("Document Type"),
+ "Document No." = field("No.");
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.good.al b/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.good.al
new file mode 100644
index 0000000..3462098
--- /dev/null
+++ b/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.good.al
@@ -0,0 +1,27 @@
+page 50630 "Sample Order Good"
+{
+ PageType = Document;
+ SourceTable = "Sales Header";
+
+ layout
+ {
+ area(Content)
+ {
+ group(General)
+ {
+ field(Amount; Rec.Amount)
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies the total amount of the order.';
+ }
+ }
+ part(Lines; "Sales Order Subform")
+ {
+ ApplicationArea = All;
+ SubPageLink = "Document Type" = field("Document Type"),
+ "Document No." = field("No.");
+ UpdatePropagation = Both;
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.md b/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.md
new file mode 100644
index 0000000..3ce676b
--- /dev/null
+++ b/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [updatepropagation, page-part, subpage, main-page, refresh, flowfield, document-lines]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Use `UpdatePropagation = Both` when line edits must refresh the main page
+
+## Description
+
+A page part does not automatically refresh its parent page when the subpage changes. `UpdatePropagation = Subpage` updates only the part; `Both` also refreshes the main page. Without `Both`, header totals, FlowFields, and FactBoxes that depend on edited lines can remain stale until another user action refreshes the page.
+
+## Best Practice
+
+Set `UpdatePropagation = Both` on a part when edits in that subpage must immediately update values rendered by the main page. Leave propagation at `Subpage` when the parent has no dependent presentation to avoid unnecessary refreshes.
+
+See sample: [`updatepropagation-both-refreshes-main-page.good.al`](updatepropagation-both-refreshes-main-page.good.al).
+
+## Anti Pattern
+
+Displaying a line-dependent total on the main page while the editable lines part updates only itself. The persisted values can be correct while the parent page continues to show an old total.
+
+See sample: [`updatepropagation-both-refreshes-main-page.bad.al`](updatepropagation-both-refreshes-main-page.bad.al).
+
+## Reference
+
+[Set different control properties](https://learn.microsoft.com/en-us/training/modules/work-with-pages/8-controls)
diff --git a/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md
index 75c8a09..d796827 100644
--- a/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md
+++ b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md
@@ -17,13 +17,13 @@ application-area: [all]
## Best Practice
-Put the validation in one local procedure and call it from both places: from the request page's `OnQueryClosePage`, so an interactive user can correct the input where they entered it, and from `OnPreReport` (or the relevant `OnPreDataItem`), so a run without a request page is still refused. Guard the interactive call on the close action β validate only when the user confirmed the run, for example `if CloseAction = Action::OK then`. The base application uses this shape; report 292, `Copy Sales Document`, validates its request-page input in `OnQueryClosePage` behind a close-action check. Mark the control with `ShowMandatory` as well, so the requirement is visible before the user submits β see `showmandatory-on-code-required-page-fields.md`. See sample: `validate-request-page-input-in-onqueryclosepage.good.al`.
+Put the validation in one local procedure and call it from both places: from the request page's `OnQueryClosePage`, so an interactive user can correct the input where they entered it, and from `OnPreReport` (or the relevant `OnPreDataItem`), so a run without a request page is still refused. Guard the interactive call on the close action β validate only when the user confirmed the run, for example `if CloseAction = Action::OK then`. The base application uses this shape; report 292, `Copy Sales Document`, validates its request-page input in `OnQueryClosePage` behind a close-action check. Mark the control with `ShowMandatory` as well, so the requirement is visible before the user submits β see `showmandatory-on-code-required-page-fields.md`. See sample: [`validate-request-page-input-in-onqueryclosepage.good.al`](validate-request-page-input-in-onqueryclosepage.good.al).
## Anti Pattern
Validating mandatory request-page input only in `OnPreReport`. The check is correct and the report is never run with bad input, but every interactive mistake costs the user the whole request page: the error arrives after the page is gone, and filters, dates, and options all have to be entered again. Reviewer signal: a `TestField`, `Error`, or blank/zero-value check in `OnPreReport` or `OnPreDataItem` against a variable that is bound to a request-page control, in a report whose request page declares no `OnQueryClosePage`.
-The mirror defect is an `OnQueryClosePage` that validates without inspecting `CloseAction`: because an error prevents the page from closing, a user who presses Cancel or Esc to abandon the report is trapped in a request page that errors on every attempt to leave it. Validating only in `OnQueryClosePage` is the third variant β the interactive path behaves well, and a job queue entry runs the report with unchecked input. See sample: `validate-request-page-input-in-onqueryclosepage.bad.al`.
+The mirror defect is an `OnQueryClosePage` that validates without inspecting `CloseAction`: because an error prevents the page from closing, a user who presses Cancel or Esc to abandon the report is trapped in a request page that errors on every attempt to leave it. Validating only in `OnQueryClosePage` is the third variant β the interactive path behaves well, and a job queue entry runs the report with unchecked input. See sample: [`validate-request-page-input-in-onqueryclosepage.bad.al`](validate-request-page-input-in-onqueryclosepage.bad.al).
## See also
diff --git a/microsoft/knowledge/upgrade/appversion-meaning-depends-on-execution-context.md b/microsoft/knowledge/upgrade/appversion-meaning-depends-on-execution-context.md
new file mode 100644
index 0000000..61e7422
--- /dev/null
+++ b/microsoft/knowledge/upgrade/appversion-meaning-depends-on-execution-context.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: upgrade
+keywords: [appversion, dataversion, moduleinfo, install-codeunit, upgrade-codeunit, version-context]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# `ModuleInfo.AppVersion` changes meaning with execution context
+
+## Description
+
+`ModuleInfo.AppVersion()` is the installed version during normal operation, the version being installed inside install code, and the target version inside upgrade code. It is therefore not the source data version during an upgrade. In upgrade code, `DataVersion()` describes the version of the existing data, whether from the currently installed app or the version most recently uninstalled.
+
+## Best Practice
+
+Interpret `AppVersion()` as the code package entering the context and `DataVersion()` as the existing data state. Prefer upgrade tags for controlling individual migration steps; when version information is needed for diagnostics or preconditions, name variables so target app version and source data version cannot be confused.
+
+## Anti Pattern
+
+Reading `AppVersion()` from an upgrade codeunit and treating it as the version being upgraded from. The comparison actually observes the target package and can skip or misroute migration logic.
+
+## Reference
+
+[Create proper installation and upgrade codeunits](https://learn.microsoft.com/en-us/training/modules/easy-application-upgrade/3-installation-upgrade-codeunits)
diff --git a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md
index 9ba7e8a..5efc751 100644
--- a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md
+++ b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md
@@ -17,10 +17,10 @@ Primary-key changes and field-type changes (for example widening `Integer` to `B
Treat primary-key and field-type changes as restricted to tables introduced in the same change. For changes on tables with existing data, design and ship the corresponding upgrade procedure (typically backed by `DataTransfer` and an upgrade tag) that guarantees the new layout is achievable for every row, and verify with concrete evidence that the existing values fit the new constraint (no PK collisions, no value-range overflow).
-See sample: `breaking-changes-only-on-tables-without-data.good.al`.
+See sample: [`breaking-changes-only-on-tables-without-data.good.al`](breaking-changes-only-on-tables-without-data.good.al).
## Anti Pattern
Changing the primary key on a base-app table, or widening / narrowing a field type on a table that has been shipping for releases, with no accompanying upgrade plan. The change compiles cleanly and may even deploy on an empty-ish tenant, then fails on customers who actually have data.
-See sample: `breaking-changes-only-on-tables-without-data.bad.al`.
+See sample: [`breaking-changes-only-on-tables-without-data.bad.al`](breaking-changes-only-on-tables-without-data.bad.al).
diff --git a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md
index 30f6ca7..8770f93 100644
--- a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md
+++ b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md
@@ -17,10 +17,10 @@ application-area: [all]
Have check triggers call query-only helpers that raise an error when an invariant fails. Put every `Insert`, `Modify`, `Delete`, `Rename`, `DataTransfer`, and other migration write behind helpers called from the matching `OnUpgrade...` trigger.
-See sample: `check-only-triggers-do-not-migrate-data.good.al`.
+See sample: [`check-only-triggers-do-not-migrate-data.good.al`](check-only-triggers-do-not-migrate-data.good.al).
## Anti Pattern
Repairing data in `OnCheckPreconditions...` or finishing migration in `OnValidateUpgrade...`. Those writes blur the phase contract and make a check alter the state it is supposed to assess.
-See sample: `check-only-triggers-do-not-migrate-data.bad.al`.
+See sample: [`check-only-triggers-do-not-migrate-data.bad.al`](check-only-triggers-do-not-migrate-data.bad.al).
diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md
index 988dfa4..830dbdb 100644
--- a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md
+++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md
@@ -17,13 +17,13 @@ Tables that can contain more than 300,000 records, and any newly added field on
For a bulk update use a `DataTransfer` variable: call `SetTables(Database::"...", Database::"...")` (source and destination may be the same table), add filters with `AddSourceFilter`, set the target value with `AddConstantValue` (or copy a source field with `AddFieldValue`), and execute with `CopyFields()`. To express multiple distinct updates against the same table, `Clear` the `DataTransfer` between executions and configure the next one.
-See sample: `datatransfer-for-bulk-init.good.al`.
+See sample: [`datatransfer-for-bulk-init.good.al`](datatransfer-for-bulk-init.good.al).
## Anti Pattern
Iterating with `FindSet(true) ... repeat ... Modify() ... until Next() = 0` to set a single field across an entire large table. On 300k+ rows this is the canonical slow-upgrade footgun.
-See sample: `datatransfer-for-bulk-init.bad.al`.
+See sample: [`datatransfer-for-bulk-init.bad.al`](datatransfer-for-bulk-init.bad.al).
## See also
diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md
index 34a406b..33173da 100644
--- a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md
+++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md
@@ -19,10 +19,10 @@ For *new fields and tables added in the same change* this is fine: nothing yet d
Use `DataTransfer` when set-based transfer is safe and row-level business logic is intentionally unnecessary β initial population of a new field is the canonical case. When an existing field's validation must run, loop through records and call `Validate(Field, Value)`; if the table's modify trigger must also run, follow with `Modify(true)`. If performance requires `DataTransfer`, document exactly which field-validation and row-modification triggers or subscribers are intentionally bypassed and verify that derived data remains correct.
-See sample: `datatransfer-skips-triggers-and-subscribers.good.al`.
+See sample: [`datatransfer-skips-triggers-and-subscribers.good.al`](datatransfer-skips-triggers-and-subscribers.good.al).
## Anti Pattern
Reaching for `DataTransfer` to update an existing field with non-trivial `OnValidate` or `OnModify` logic, without confirming that both validation and row-modification subscribers can be skipped. Replacing it with only `Modify(true)` is also incomplete when field validation is required; call `Validate` for that field first.
-See sample: `datatransfer-skips-triggers-and-subscribers.bad.al`.
+See sample: [`datatransfer-skips-triggers-and-subscribers.bad.al`](datatransfer-skips-triggers-and-subscribers.bad.al).
diff --git a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md
index cf585eb..868b61e 100644
--- a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md
+++ b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md
@@ -17,10 +17,10 @@ When upgrade code encounters unexpected data β a record it expected to find, a
When an upgrade procedure detects something missing, call `Session.LogMessage` with a stable event ID, classify the message verbosity (typically `Warning`), and `exit` the procedure so the rest of the upgrade can proceed. The platform telemetry then surfaces the situation to the partner without breaking the customer.
-See sample: `do-not-block-upgrade-on-data-errors.good.al`.
+See sample: [`do-not-block-upgrade-on-data-errors.good.al`](do-not-block-upgrade-on-data-errors.good.al).
## Anti Pattern
Calling `Record.Get(Key)` (or any other erroring API) and letting the error propagate out of the upgrade trigger. The first tenant with imperfect data fails to upgrade, and the failure surfaces as a hard upgrade error rather than as a telemetry signal.
-See sample: `do-not-block-upgrade-on-data-errors.bad.al`.
+See sample: [`do-not-block-upgrade-on-data-errors.bad.al`](do-not-block-upgrade-on-data-errors.bad.al).
diff --git a/microsoft/knowledge/upgrade/enum-values-additive-at-end.md b/microsoft/knowledge/upgrade/enum-values-additive-at-end.md
index 4de929b..332efa6 100644
--- a/microsoft/knowledge/upgrade/enum-values-additive-at-end.md
+++ b/microsoft/knowledge/upgrade/enum-values-additive-at-end.md
@@ -17,13 +17,13 @@ An AL `enum` is a fixed list of ordinal-named values. Persisted rows reference e
When adding an enum value, place it after the last existing `value(N; ...)` entry, with an ordinal strictly greater than every existing one. Never renumber existing entries. To retire a value, do not delete it: mark it `ObsoleteState = Pending` (and later `Removed`) with `ObsoleteReason` and `ObsoleteTag` so the ordinal remains taken.
-See sample: `enum-values-additive-at-end.good.al`.
+See sample: [`enum-values-additive-at-end.good.al`](enum-values-additive-at-end.good.al).
## Anti Pattern
Inserting a value between existing entries ("just put `NewMiddleValue` between `First` and `Second`"), or removing a value from the enum without first going through `ObsoleteState = Pending` β `Removed`. Every row whose persisted ordinal matched the removed or shifted value now reads as a different member.
-See sample: `enum-values-additive-at-end.bad.al`.
+See sample: [`enum-values-additive-at-end.bad.al`](enum-values-additive-at-end.bad.al).
## See also
diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md
index 6324836..5cbdec8 100644
--- a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md
+++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md
@@ -17,13 +17,13 @@ On the first install of an extension on a tenant the platform records a zero dat
In `OnInstallAppPerCompany`, fetch the current `ModuleInfo` via `NavApp.GetCurrentModuleInfo`, compare `AppInfo.DataVersion()` to `Version.Create('0.0.0.0')`, and run first-install seed logic only when they match. On a non-zero data version, follow the reinstall path or exit.
-See sample: `first-install-dataversion-zero-check.good.al`.
+See sample: [`first-install-dataversion-zero-check.good.al`](first-install-dataversion-zero-check.good.al).
## Anti Pattern
Treating `OnInstallAppPerCompany` as if it always implies "fresh tenant". The trigger also fires when reinstalling over an existing data set; without the `0.0.0.0` guard, first-install seed code can run again and duplicate rows.
-See sample: `first-install-dataversion-zero-check.bad.al`.
+See sample: [`first-install-dataversion-zero-check.bad.al`](first-install-dataversion-zero-check.bad.al).
## See also
diff --git a/microsoft/knowledge/upgrade/guard-database-reads.md b/microsoft/knowledge/upgrade/guard-database-reads.md
index c5bc206..09a99e3 100644
--- a/microsoft/knowledge/upgrade/guard-database-reads.md
+++ b/microsoft/knowledge/upgrade/guard-database-reads.md
@@ -17,10 +17,10 @@ Inside an upgrade codeunit (or any procedure transitively invoked from `OnUpgrad
Wrap every read in an `if`. `if Item.Get(No) then ...`, `if Customer.FindSet() then;`, `if not Vendor.FindLast() then exit;`. The empty-then form `if Customer.FindSet() then;` is the idiomatic way to attempt a read whose only purpose is to position a record, while swallowing the "not found" case.
-See sample: `guard-database-reads.good.al`.
+See sample: [`guard-database-reads.good.al`](guard-database-reads.good.al).
## Anti Pattern
Calling `Item.Get()`, `Customer.FindSet()`, or `Vendor.FindLast()` bare in upgrade code. The first tenant whose data does not match the upgrade's assumptions will fail to upgrade.
-See sample: `guard-database-reads.bad.al`.
+See sample: [`guard-database-reads.bad.al`](guard-database-reads.bad.al).
diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md
index 4733ef2..bfa0f03 100644
--- a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md
+++ b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md
@@ -23,10 +23,10 @@ Several legitimate cases do NOT need upgrade code:
When a new field on an existing table has an `InitValue` that matters, ship an upgrade procedure that walks the existing rows and sets the field to the same value β typically via `DataTransfer.AddConstantValue` for performance β guarded by an upgrade tag.
-See sample: `initvalue-does-not-update-existing-rows.good.al`.
+See sample: [`initvalue-does-not-update-existing-rows.good.al`](initvalue-does-not-update-existing-rows.good.al).
## Anti Pattern
Adding a field with `InitValue = true;` (or any non-default `InitValue`) and shipping no upgrade code. Existing rows silently carry the datatype default, leaving the table in two states: rows created before the upgrade with the wrong value, and rows created after with the right one.
-See sample: `initvalue-does-not-update-existing-rows.bad.al`.
+See sample: [`initvalue-does-not-update-existing-rows.bad.al`](initvalue-does-not-update-existing-rows.bad.al).
diff --git a/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.bad.al b/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.bad.al
new file mode 100644
index 0000000..2a92d11
--- /dev/null
+++ b/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.bad.al
@@ -0,0 +1,28 @@
+codeunit 50641 "Sample Upgrade Part One"
+{
+ Subtype = Upgrade;
+
+ trigger OnUpgradePerCompany()
+ begin
+ CreateUpgradeState();
+ end;
+
+ local procedure CreateUpgradeState()
+ begin
+ end;
+}
+
+codeunit 50642 "Sample Upgrade Part Two"
+{
+ Subtype = Upgrade;
+
+ trigger OnUpgradePerCompany()
+ begin
+ // This can run before Part One; object IDs do not sequence upgrade codeunits.
+ MigrateDataThatRequiresUpgradeState();
+ end;
+
+ local procedure MigrateDataThatRequiresUpgradeState()
+ begin
+ end;
+}
diff --git a/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.good.al b/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.good.al
new file mode 100644
index 0000000..42346a8
--- /dev/null
+++ b/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.good.al
@@ -0,0 +1,18 @@
+codeunit 50640 "Sample Upgrade Good"
+{
+ Subtype = Upgrade;
+
+ trigger OnUpgradePerCompany()
+ begin
+ CreateUpgradeState();
+ MigrateDependentData();
+ end;
+
+ local procedure CreateUpgradeState()
+ begin
+ end;
+
+ local procedure MigrateDependentData()
+ begin
+ end;
+}
diff --git a/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.md b/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.md
new file mode 100644
index 0000000..b11a842
--- /dev/null
+++ b/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: upgrade
+keywords: [install-codeunit, upgrade-codeunit, execution-order, subtype-install, subtype-upgrade, sequencing]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Separate install or upgrade codeunits have no execution order
+
+## Description
+
+An extension can contain multiple `Install` or `Upgrade` codeunits, but Business Central does not guarantee the order in which codeunits of the same subtype execute. Upgrade trigger phases are ordered globally, yet one codeunit's `OnUpgradePerCompany` must not assume another codeunit's same-phase trigger already ran. Object ID and source-file order do not provide sequencing.
+
+## Best Practice
+
+Keep separate install or upgrade codeunits independent. When two steps have a real dependency, coordinate them from one owning trigger in the required order; use upgrade tags to make each completed step idempotent.
+
+See sample: [`install-and-upgrade-codeunits-have-no-order.good.al`](install-and-upgrade-codeunits-have-no-order.good.al).
+
+## Anti Pattern
+
+Splitting dependent steps into separate codeunits and relying on names, object IDs, or declaration order. The dependent codeunit can run first and fail or observe partially migrated data.
+
+See sample: [`install-and-upgrade-codeunits-have-no-order.bad.al`](install-and-upgrade-codeunits-have-no-order.bad.al).
+
+## Reference
+
+[Create proper installation and upgrade codeunits](https://learn.microsoft.com/en-us/training/modules/easy-application-upgrade/3-installation-upgrade-codeunits)
diff --git a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md
index 12f432f..52c3989 100644
--- a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md
+++ b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md
@@ -17,10 +17,10 @@ An install codeunit runs when an extension is installed for the first time or an
Use `Subtype = Install` for first-install and reinstall initialization. Put version migration in a separate `Subtype = Upgrade` codeunit and enter it from `OnUpgradePerCompany` or `OnUpgradePerDatabase`.
-See sample: `install-code-does-not-run-on-version-upgrade.good.al`.
+See sample: [`install-code-does-not-run-on-version-upgrade.good.al`](install-code-does-not-run-on-version-upgrade.good.al).
## Anti Pattern
Putting a schema or data migration only in an install trigger and expecting it to run when a higher app version is upgraded. The migration is never invoked on that path.
-See sample: `install-code-does-not-run-on-version-upgrade.bad.al`.
+See sample: [`install-code-does-not-run-on-version-upgrade.bad.al`](install-code-does-not-run-on-version-upgrade.bad.al).
diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md
index b9e5e13..3095655 100644
--- a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md
+++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md
@@ -17,10 +17,10 @@ Triggers such as `OnValidateUpgradePerCompany` run on every upgrade pass. A full
Filter directly to invalid rows and use `IsEmpty` or another bounded existence check where possible. If a broad validation is unavoidable, document the invariant that requires it and keep all data changes in `OnUpgrade...`.
-See sample: `minimize-onvalidate-upgrade-triggers.good.al`.
+See sample: [`minimize-onvalidate-upgrade-triggers.good.al`](minimize-onvalidate-upgrade-triggers.good.al).
## Anti Pattern
Reading every record in `OnValidateUpgradePerCompany` when a filtered existence check can prove the same invariant. The scan repeats on every upgrade.
-See sample: `minimize-onvalidate-upgrade-triggers.bad.al`.
+See sample: [`minimize-onvalidate-upgrade-triggers.bad.al`](minimize-onvalidate-upgrade-triggers.bad.al).
diff --git a/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.bad.al b/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.bad.al
new file mode 100644
index 0000000..44781a7
--- /dev/null
+++ b/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.bad.al
@@ -0,0 +1,41 @@
+table 50263 "Sales Order Ext"
+{
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20]) { }
+ field(2; "Shipping Agent Code"; Code[10]) { TableRelation = "Shipping Agent"; }
+ field(3; "Legacy Carrier Code"; Code[10]) { }
+ }
+
+ keys
+ {
+ key(PK; "No.") { Clustered = true; }
+ }
+}
+
+codeunit 50261 "Upgrade All Companies"
+{
+ Subtype = Upgrade;
+
+ trigger OnUpgradePerDatabase()
+ var
+ Company: Record Company;
+ SalesOrderExt: Record "Sales Order Ext";
+ begin
+ // Reaches into every company from one session. Each company also has its own
+ // upgrade session, triggers and subscribers run in the calling context, and a
+ // data error in any company aborts the whole upgrade.
+ if Company.FindSet() then
+ repeat
+ SalesOrderExt.ChangeCompany(Company.Name);
+ SalesOrderExt.SetRange("Shipping Agent Code", '');
+ if SalesOrderExt.FindSet(true) then
+ repeat
+ SalesOrderExt.Validate("Shipping Agent Code", SalesOrderExt."Legacy Carrier Code");
+ SalesOrderExt.Modify(true);
+ until SalesOrderExt.Next() = 0;
+ until Company.Next() = 0;
+ end;
+}
diff --git a/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.good.al b/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.good.al
new file mode 100644
index 0000000..c6374fc
--- /dev/null
+++ b/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.good.al
@@ -0,0 +1,96 @@
+table 50262 "Sales Order Ext"
+{
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20]) { }
+ field(2; "Shipping Agent Code"; Code[10]) { TableRelation = "Shipping Agent"; }
+ field(3; "Legacy Carrier Code"; Code[10]) { }
+ }
+
+ keys
+ {
+ key(PK; "No.") { Clustered = true; }
+ }
+}
+
+codeunit 50260 "Upgrade Current Company"
+{
+ Subtype = Upgrade;
+
+ // The platform runs this trigger once per company, in that company's own session.
+ trigger OnUpgradePerCompany()
+ begin
+ UpgradeShippingAgentCodes();
+ end;
+
+ local procedure UpgradeShippingAgentCodes()
+ var
+ SalesOrderExt: Record "Sales Order Ext";
+ UpgradeTag: Codeunit "Upgrade Tag";
+ begin
+ if UpgradeTag.HasUpgradeTag(ShippingAgentUpgradeTag()) then
+ exit;
+
+ // Only the current company's rows; triggers, events, and the tag all apply here.
+ SalesOrderExt.SetRange("Shipping Agent Code", '');
+ if SalesOrderExt.FindSet(true) then
+ repeat
+ SalesOrderExt.Validate("Shipping Agent Code", SalesOrderExt."Legacy Carrier Code");
+ SalesOrderExt.Modify(true);
+ until SalesOrderExt.Next() = 0;
+
+ UpgradeTag.SetUpgradeTag(ShippingAgentUpgradeTag());
+ end;
+
+ local procedure ShippingAgentUpgradeTag(): Code[250]
+ begin
+ exit('CONTOSO-1001-ShippingAgentCode-20260101');
+ end;
+}
+
+codeunit 50264 "Shipping Agent Feat. Data Upd." implements "Feature Data Update"
+{
+ // Read-only preflight: counting rows across companies to report scope is allowed.
+ procedure IsDataUpdateRequired(): Boolean
+ var
+ Company: Record Company;
+ SalesOrderExt: Record "Sales Order Ext";
+ begin
+ if Company.FindSet() then
+ repeat
+ SalesOrderExt.ChangeCompany(Company.Name);
+ SalesOrderExt.SetRange("Shipping Agent Code", '');
+ if not SalesOrderExt.IsEmpty() then
+ exit(true);
+ until Company.Next() = 0;
+ exit(false);
+ end;
+
+ procedure ReviewData()
+ begin
+ end;
+
+ // Feature Management runs this once per company, in that company.
+ procedure UpdateData(FeatureDataUpdateStatus: Record "Feature Data Update Status")
+ var
+ SalesOrderExt: Record "Sales Order Ext";
+ begin
+ SalesOrderExt.SetRange("Shipping Agent Code", '');
+ if SalesOrderExt.FindSet(true) then
+ repeat
+ SalesOrderExt.Validate("Shipping Agent Code", SalesOrderExt."Legacy Carrier Code");
+ SalesOrderExt.Modify(true);
+ until SalesOrderExt.Next() = 0;
+ end;
+
+ procedure AfterUpdate(FeatureDataUpdateStatus: Record "Feature Data Update Status")
+ begin
+ end;
+
+ procedure GetTaskDescription(): Text
+ begin
+ exit('Copies legacy carrier codes to the shipping agent code.');
+ end;
+}
diff --git a/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.md b/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.md
new file mode 100644
index 0000000..3295bba
--- /dev/null
+++ b/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.md
@@ -0,0 +1,36 @@
+---
+bc-version: [all]
+domain: upgrade
+keywords: [changecompany, cross-company, onupgradepercompany, onupgradeperdatabase, feature-data-update, taskscheduler, multi-company, company-context]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Upgrade code must not use ChangeCompany
+
+## Description
+
+The platform upgrades each company in its own context: `OnUpgradePerCompany` (like the other `PerCompany` upgrade and install triggers) runs once per company, in a separate system session opened for that company, while `PerDatabase` triggers run in a session that opens no company. Calling `ChangeCompany()` in upgrade code reaches from the company being upgraded into another company. Cross-company work bypasses the platform's per-company execution boundary, making execution order and migration ownership difficult to reason about and potentially repeating work. A cross-company read cannot assume that the other company's migration has already run, so it can observe pre-upgrade or post-upgrade data. Per-company upgrade tags are set for the current company only, so a tag set after writing to company B is recorded for company A, which can cause B's own session to repeat the migration. Execution context does not move with `ChangeCompany`: table triggers and trigger-event subscribers still run in the calling company (see `changecompany-runs-triggers-in-the-calling-company`). Data and side effects then land in different companies, and an error in another company's data aborts the current company's upgrade. The same applies to the migration path of a feature-switch data update: the `UpdateData` and `AfterUpdate` methods of a `Feature Data Update` implementation. Feature Management runs them for one company's status row, either as a task scheduled in that company or in the current session. The interface's preflight methods, `IsDataUpdateRequired` and `ReviewData`, are a separate phase that reports scope before any update is scheduled.
+
+## Best Practice
+
+Upgrade code operates only on the company it is running in. Put per-company data migration in `OnUpgradePerCompany` (or a helper reachable only from it), guard it with a per-company upgrade tag, and let the platform invoke it for every company. Reserve `OnUpgradePerDatabase` for tables with `DataPerCompany = false` and other database-wide state; it needs no `ChangeCompany`. A feature data update implements `UpdateData` and `AfterUpdate` against the current company only. Its read-only preflight may use `ChangeCompany` to count or inspect rows in other companies, because it migrates nothing. When code outside the upgrade pipeline must start work in other companies, schedule it in each company with `TaskScheduler.CreateTask` and the company name, as Feature Management does, rather than writing there through `ChangeCompany`. The scheduled task runs in the target company, so triggers, events, permissions, and upgrade tags all use that company.
+
+See sample: [`no-changecompany-in-upgrade.good.al`](no-changecompany-in-upgrade.good.al).
+
+## Anti Pattern
+
+A loop over the `Company` table in `OnUpgradePerDatabase` or `OnUpgradePerCompany` that calls `ChangeCompany(Company.Name)` on a record or `RecordRef` and then reads, inserts, modifies, or deletes data. Another form is a `Feature Data Update` implementation whose `UpdateData` or `AfterUpdate` does the same to update every company from one task. On these paths reads are not exempt: they observe another company whose upgrade state is unknown.
+
+Detection signal: `Record.ChangeCompany()` or `RecordRef.ChangeCompany()` with a company-name argument in a codeunit with `Subtype = Upgrade` or in any procedure transitively reachable from its upgrade triggers, or in a `Feature Data Update` implementation's `UpdateData` or `AfterUpdate` or any procedure transitively reachable from them. Do not flag `ChangeCompany` in `IsDataUpdateRequired`, `ReviewData`, or helpers reachable only from them; a helper shared with `UpdateData` or `AfterUpdate` is in scope. Do not flag the parameterless `ChangeCompany()`, which only points the variable back at the current company, or `ChangeCompany` in ordinary runtime code; `changecompany-runs-triggers-in-the-calling-company` governs that.
+
+See sample: [`no-changecompany-in-upgrade.bad.al`](no-changecompany-in-upgrade.bad.al).
+
+## References
+
+- Upgrading extensions, Upgrade triggers: `PerCompany` triggers run once per company, each in its own system session for that company β https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-upgrading-extensions
+- Record.ChangeCompany method, Remarks: triggers still run in the current company β https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-changecompany-method
+- Interface "Feature Data Update" β https://learn.microsoft.com/en-us/dynamics365/business-central/application/system-application/interface/system.environment.configuration.feature-data-update
+- `FeatureManagementImpl.UpdateData` calls the interface's `UpdateData` then `AfterUpdate`; `ReviewData` calls `IsDataUpdateRequired` and `ReviewData` β https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Feature%20Key/src/FeatureManagementImpl.Codeunit.al
+- `FeatureManagementImpl.CreateTask` schedules `Update Feature Data` with `TaskScheduler.CreateTask` for the status row's company β https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Feature%20Key/src/FeatureManagementImpl.Codeunit.al
diff --git a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md
index eb644b6..f04fb40 100644
--- a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md
+++ b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md
@@ -19,10 +19,10 @@ The rule applies inside any codeunit with `Subtype = Upgrade` and to any procedu
Defer external calls to runtime code. If a piece of upgrade work conceptually needs data from an external service, set a flag or write a queue row during upgrade and have the runtime code make the call later (for example on first user sign-in or via job queue), where retries and degraded modes are tractable.
-See sample: `no-external-calls-in-upgrade.good.al`.
+See sample: [`no-external-calls-in-upgrade.good.al`](no-external-calls-in-upgrade.good.al).
## Anti Pattern
Calling `HttpClient.Get`, `HttpClient.Post`, or DotNet interop methods from `OnUpgradePerCompany`, `OnUpgradePerDatabase`, or any procedure they invoke.
-See sample: `no-external-calls-in-upgrade.bad.al`.
+See sample: [`no-external-calls-in-upgrade.bad.al`](no-external-calls-in-upgrade.bad.al).
diff --git a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md
index cb008ac..64a54f2 100644
--- a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md
+++ b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md
@@ -17,10 +17,10 @@ application-area: [all]
Stage the deprecation across releases. Step 1: mark `Pending` with reason and tag; consumers are warned but data and code keep working. Step 2: in a later release, transition to `Removed` and (if persisted data references the element) ship an upgrade procedure that migrates that data β gated by an upgrade tag. The standard mechanic for retiring the actual implementation body is to remove the `#if not CLEAN` block in the same release that flips the state to `Removed`.
-See sample: `obsolete-pending-to-removed-staging.good.al`.
+See sample: [`obsolete-pending-to-removed-staging.good.al`](obsolete-pending-to-removed-staging.good.al).
## Anti Pattern
Jumping straight to `ObsoleteState = Removed` without a prior `Pending` release. Consumers have no deprecation window to migrate and any data still referencing the element is stranded. Equally wrong: leaving an element `Pending` indefinitely and never staging its removal β the deprecation never completes.
-See sample: `obsolete-pending-to-removed-staging.bad.al`.
+See sample: [`obsolete-pending-to-removed-staging.bad.al`](obsolete-pending-to-removed-staging.bad.al).
diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md
index d6ec37a..b468437 100644
--- a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md
+++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md
@@ -22,13 +22,13 @@ In both forms, the reason should name the replacement and the tag should identif
For an object or field, set all three properties together. For a method, variable, or event, provide both `[Obsolete]` arguments. Keep the original tag stable through the lifecycle rather than changing it to a planned removal version.
-See sample: `obsoletion-requires-reason-and-tag.good.al`.
+See sample: [`obsoletion-requires-reason-and-tag.good.al`](obsoletion-requires-reason-and-tag.good.al).
## Anti Pattern
Setting only `ObsoleteState = Pending`/`Removed` on an object or field, or using `[Obsolete('', '')]` on a method, variable, or event. Both forms produce deprecation metadata without useful replacement guidance or traceability.
-See sample: `obsoletion-requires-reason-and-tag.bad.al`.
+See sample: [`obsoletion-requires-reason-and-tag.bad.al`](obsoletion-requires-reason-and-tag.bad.al).
## See also
diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md
index e5e1983..0bba7c2 100644
--- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md
+++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md
@@ -19,10 +19,10 @@ Registration is not install-time seeding. When an extension is installed into an
In the upgrade codeunit, guard work with `HasUpgradeTag` and call `SetUpgradeTag` only after successful completion. Seed the same tag explicitly from `OnInstallAppPerCompany` when first-install logic should not run as a later upgrade. Also add historical per-company tags to `OnGetPerCompanyUpgradeTags` so `SetAllUpgradeTags` marks them complete for newly created companies. Keep the tag definition shared so all paths use the exact same value.
-See sample: `register-upgrade-tags-with-subscribers.good.al`.
+See sample: [`register-upgrade-tags-with-subscribers.good.al`](register-upgrade-tags-with-subscribers.good.al).
## Anti Pattern
Assuming an `OnGetPerCompanyUpgradeTags` subscriber sets tags during extension installation, or omitting the subscriber and allowing old upgrade steps to run when `SetAllUpgradeTags` initializes a new company. The subscriber supplies a list; only `SetAllUpgradeTags` or an explicit `SetUpgradeTag` call persists it.
-See sample: `register-upgrade-tags-with-subscribers.bad.al`.
+See sample: [`register-upgrade-tags-with-subscribers.bad.al`](register-upgrade-tags-with-subscribers.bad.al).
diff --git a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md
index 0b441e6..c4558b7 100644
--- a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md
+++ b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md
@@ -19,10 +19,10 @@ This is the opposite of a load-bearing concern: code that MUST run during the up
In a runtime procedure that performs non-essential side effects, guard the side-effect block with `if GetExecutionContext() = ExecutionContext::Upgrade then exit;` and include a brief comment explaining what is being skipped and why.
-See sample: `skip-nonessential-work-via-execution-context.good.al`.
+See sample: [`skip-nonessential-work-via-execution-context.good.al`](skip-nonessential-work-via-execution-context.good.al).
## Anti Pattern
Using `GetExecutionContext()` to *enable* upgrade behaviour from outside an upgrade codeunit. Upgrade behaviour belongs in a codeunit with `Subtype = Upgrade`; runtime code should only use the check to *suppress* optional work.
-See sample: `skip-nonessential-work-via-execution-context.bad.al`.
+See sample: [`skip-nonessential-work-via-execution-context.bad.al`](skip-nonessential-work-via-execution-context.bad.al).
diff --git a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md
index dcc21e3..078e109 100644
--- a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md
+++ b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md
@@ -19,10 +19,10 @@ Empty `OnUpgradePerCompany` / `OnUpgradePerDatabase` triggers are acceptable β
Each upgrade trigger contains an ordered list of procedure calls, one per feature: `UpgradeFeatureA();` `UpgradeFeatureB();`. Each procedure handles its own upgrade tag, its own data work, and can be added or removed independently.
-See sample: `triggers-call-helpers-not-implementations.good.al`.
+See sample: [`triggers-call-helpers-not-implementations.good.al`](triggers-call-helpers-not-implementations.good.al).
## Anti Pattern
Implementing record loops, `ModifyAll`, or other data work directly in the trigger body. The trigger then mixes orchestration with implementation, and adding a second feature requires editing the trigger rather than appending one line.
-See sample: `triggers-call-helpers-not-implementations.bad.al`.
+See sample: [`triggers-call-helpers-not-implementations.bad.al`](triggers-call-helpers-not-implementations.bad.al).
diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md
index a9fee7c..8bf558d 100644
--- a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md
+++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md
@@ -17,10 +17,10 @@ A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upg
Place every piece of upgrade logic in a codeunit declared with `Subtype = Upgrade;` and expose entry points via the two triggers `OnUpgradePerCompany` and `OnUpgradePerDatabase`. Helper procedures may live in normal codeunits, but they inherit the upgrade-context rules (guarded reads, no external calls, upgrade tags, etc.) when called from an upgrade trigger.
-See sample: `upgrade-codeunit-subtype.good.al`.
+See sample: [`upgrade-codeunit-subtype.good.al`](upgrade-codeunit-subtype.good.al).
## Anti Pattern
Putting upgrade-style logic in a regular codeunit that the platform never invokes during upgrade β for example a normal codeunit with a manually invented "RunUpgrade" procedure that nothing wires to the upgrade pipeline. The migration code will simply not run.
-See sample: `upgrade-codeunit-subtype.bad.al`.
+See sample: [`upgrade-codeunit-subtype.bad.al`](upgrade-codeunit-subtype.bad.al).
diff --git a/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.bad.al b/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.bad.al
new file mode 100644
index 0000000..82065f0
--- /dev/null
+++ b/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.bad.al
@@ -0,0 +1,32 @@
+local procedure UpgradeCustomerFields()
+begin
+ if not UpgradeTag.HasUpgradeTag(GetCustomerDiscountFieldTag()) then begin
+ Customer.SetLoadFields("Discount %", "Customer Posting Group");
+ if Customer.FindSet() then
+ repeat
+ if (Customer."Discount %" = 0) and (Customer."Customer Posting Group" <> '') then begin
+ Customer."Discount %" := 5;
+ Customer.Modify();
+ end;
+ until Customer.Next() = 0;
+ UpgradeTag.SetUpgradeTag(GetCustomerDiscountFieldTag());
+
+ // BUG: a second, unrelated migration's tag check nested inside the
+ // first migration's guarded body. Neither tag can be checked,
+ // skipped, or fixed independently of the other - a failure or a
+ // deliberate skip of the discount migration silently takes the
+ // shipping-agent migration down with it, and nothing in the
+ // Upgrade Tags table records that the second step ran on its own.
+ if not UpgradeTag.HasUpgradeTag(GetCustomerShippingAgentFieldTag()) then begin
+ Customer.SetLoadFields("Shipping Agent Code");
+ if Customer.FindSet() then
+ repeat
+ if Customer."Shipping Agent Code" = '' then begin
+ Customer."Shipping Agent Code" := DefaultShippingAgentCode();
+ Customer.Modify();
+ end;
+ until Customer.Next() = 0;
+ UpgradeTag.SetUpgradeTag(GetCustomerShippingAgentFieldTag());
+ end;
+ end;
+end;
diff --git a/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.good.al b/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.good.al
new file mode 100644
index 0000000..e121570
--- /dev/null
+++ b/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.good.al
@@ -0,0 +1,40 @@
+local procedure UpgradeCustomerDiscountField()
+begin
+ if UpgradeTag.HasUpgradeTag(GetCustomerDiscountFieldTag()) then
+ exit;
+
+ Customer.SetLoadFields("Discount %", "Customer Posting Group");
+ if Customer.FindSet() then
+ repeat
+ // A business-data safety condition inside this one migration's
+ // loop is not a second migration hiding inside the first -
+ // Microsoft's own upgrade-tag example nests exactly this shape
+ // (a corruption guard, then a redundant-write guard) inside a
+ // single tagged procedure.
+ if (Customer."Discount %" = 0) and (Customer."Customer Posting Group" <> '') then begin
+ Customer."Discount %" := 5;
+ Customer.Modify();
+ end;
+ until Customer.Next() = 0;
+
+ UpgradeTag.SetUpgradeTag(GetCustomerDiscountFieldTag());
+end;
+
+// A second, genuinely unrelated migration gets its own tag and its own
+// top-level procedure - not nested inside the first one's guarded body.
+local procedure UpgradeCustomerShippingAgentField()
+begin
+ if UpgradeTag.HasUpgradeTag(GetCustomerShippingAgentFieldTag()) then
+ exit;
+
+ Customer.SetLoadFields("Shipping Agent Code");
+ if Customer.FindSet() then
+ repeat
+ if Customer."Shipping Agent Code" = '' then begin
+ Customer."Shipping Agent Code" := DefaultShippingAgentCode();
+ Customer.Modify();
+ end;
+ until Customer.Next() = 0;
+
+ UpgradeTag.SetUpgradeTag(GetCustomerShippingAgentFieldTag());
+end;
diff --git a/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.md b/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.md
new file mode 100644
index 0000000..d2384f8
--- /dev/null
+++ b/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.md
@@ -0,0 +1,34 @@
+---
+bc-version: [all]
+domain: upgrade
+keywords: [upgrade-tag, nesting, complexity, upgrade-per-company, upgrade-per-database]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Never nest upgrade tag checks or blend two migrations under one tag
+
+## Description
+
+Upgrade tag *checks* should stay flat: never nest one tag's existence check inside another tag's guarded body, and never let one tagged procedure quietly perform a second, functionally distinct migration β that turns two upgrade steps into one that can't be tracked, skipped, or fixed independently, which is exactly what separate tags exist to prevent. That is the specific nesting Microsoft's own guidance warns against ("Keep tags simple by limiting nesting tags to two levels").
+
+That is not a limit on how much conditional logic a single migration's own loop body may contain. Microsoft's own worked example for upgrade tags nests a record loop with two business-data safety conditions β a corruption guard, then a redundant-write guard β inside one `if UpgradeTagMgt.HasUpgradeTag(...) then exit;`-guarded procedure, and its own design guidance separately *requires* this: "Implement extra safety checks to avoid data corruption, even though you're using upgrade tags." A business-data guard that protects the single migration a tag represents is not a second migration hiding inside the first, however many `if` levels it takes.
+
+Upgrade code runs unattended, once, against production data with no chance to interactively debug a wrong branch β which is why mixing two migrations under one tag, or losing track of which tag guards which step, is a genuinely higher-cost mistake here than the equivalent would be in ordinary application code.
+
+## Best Practice
+
+One tag, one migration: exit early if the tag is already set, then run the one upgrade step that tag represents β including as many business-data safety conditions as that single step's own correctness requires, nested however deep the logic actually needs. Reach for a second, separately tagged migration only when the nested logic is doing genuinely unrelated work (a different table, a different field, a different concern) that could legitimately be skipped, retried, or fixed on its own.
+
+See sample: [`upgrade-tag-logic-must-not-nest-deeply.good.al`](upgrade-tag-logic-must-not-nest-deeply.good.al).
+
+## Anti Pattern
+
+Checking one upgrade tag inside the guarded body of another, or writing two functionally unrelated migrations β different tables, different concerns β under a single tag so neither can be tracked, skipped, or fixed independently of the other. A record loop with business-data safety conditions inside one tagged migration's own body is not this anti-pattern, even several `if` levels deep, as long as every condition serves that one migration.
+
+See sample: [`upgrade-tag-logic-must-not-nest-deeply.bad.al`](upgrade-tag-logic-must-not-nest-deeply.bad.al).
+
+## Source
+
+Microsoft's own "Upgrading Extensions" guidance, Design considerations: "Keep tags simple by limiting nesting tags to two levels. Complicated if statements can lead to problems." β https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-upgrading-extensions#using-upgrade-tags-to-control-upgrade-code
diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md
index 62347d1..bb7649f 100644
--- a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md
+++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md
@@ -17,13 +17,13 @@ Each piece of upgrade logic must run exactly once per company (or database) acro
Every upgrade procedure starts with a `HasUpgradeTag` guard and ends with `SetUpgradeTag` once the work is committed. Each feature gets its own tag string so features can be re-run independently if needed.
-See sample: `use-upgrade-tags-not-version-checks.good.al`.
+See sample: [`use-upgrade-tags-not-version-checks.good.al`](use-upgrade-tags-not-version-checks.good.al).
## Anti Pattern
Branching on `MyApp.DataVersion().Major > N`, or chains of `< N` / `< M` to decide which upgrade step to run. Such code becomes unmaintainable after a few releases and silently does the wrong thing on tenants that skip versions.
-See sample: `use-upgrade-tags-not-version-checks.bad.al`.
+See sample: [`use-upgrade-tags-not-version-checks.bad.al`](use-upgrade-tags-not-version-checks.bad.al).
## See also
diff --git a/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md
index 7988326..66fe36e 100644
--- a/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md
+++ b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md
@@ -21,7 +21,7 @@ LLMs treat one carrier as universal. Some assume the caption is serialised and r
Establish which schema versions the field is served under before changing anything about its enum. Under schema 2.0 (Microsoft's API v2.0, an explicit `$schemaversion=2.0` in the consumer contract, or another reliable context signal) the member name is the contract: keep names stable, put wording changes in `Caption`, add a value by appending a new name with an ordinal above every existing one, and retire a value through `ObsoleteState` rather than by deleting it. For a custom API that clients may still call as schema 1.0, any install of BC 17 to 23 or a caller that pins 1.0, the caption is a contract as well: change neither name nor caption in place, or publish the change as a new `APIVersion` on a new page object. A rename is out in every case: AppSourceCop AS0082 rejects it against a baseline, and dependent extensions bind to the name.
-See sample: `api-enum-values-are-a-contract-by-name-not-ordinal.good.al`.
+See sample: [`api-enum-values-are-a-contract-by-name-not-ordinal.good.al`](api-enum-values-are-a-contract-by-name-not-ordinal.good.al).
## Anti Pattern
@@ -31,7 +31,7 @@ Detection signal: a diff hunk that changes the name in a `value(...)` line while
The mirror image is a review defect: suppressing a caption-change finding because "the API serialises names". That holds only under schema 2.0. Do not flag a `Caption` change when the reviewer can establish schema 2.0 for every consumer; on a custom API where clients may select schema 1.0, report a caption change on an exposed value as a consumer-visible change and ask for versioning. A value appended at the end changes no contract under either schema and is never a finding.
-See sample: `api-enum-values-are-a-contract-by-name-not-ordinal.bad.al`.
+See sample: [`api-enum-values-are-a-contract-by-name-not-ordinal.bad.al`](api-enum-values-are-a-contract-by-name-not-ordinal.bad.al).
## See also
diff --git a/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.bad.al b/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.bad.al
new file mode 100644
index 0000000..8b8e240
--- /dev/null
+++ b/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.bad.al
@@ -0,0 +1,27 @@
+page 50101 "Customer Info API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'customerInfo';
+ EntitySetName = 'customerInfos';
+ SourceTable = Customer;
+ ODataKeyFields = "No.";
+ InsertAllowed = true;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(General)
+ {
+ field(customerNo; Rec."No.")
+ {
+ Editable = false; // consumer must supply "No." on POST β this rejects it
+ }
+ field(name; Rec.Name) { }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.good.al b/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.good.al
new file mode 100644
index 0000000..af6c5de
--- /dev/null
+++ b/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.good.al
@@ -0,0 +1,25 @@
+page 50101 "Customer Info API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'customerInfo';
+ EntitySetName = 'customerInfos';
+ SourceTable = Customer;
+ ODataKeyFields = "No.";
+ InsertAllowed = true;
+ DelayedInsert = true;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(General)
+ {
+ field(customerNo; Rec."No.") { }
+ field(name; Rec.Name) { }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.md b/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.md
new file mode 100644
index 0000000..84887c1
--- /dev/null
+++ b/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: web-services
+keywords: [api-page, key-fields, editable, insert, odata]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Keep Consumer-Provided Key Fields Editable on API Pages
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+
+A field listed in `ODataKeyFields` cannot have `Editable = false` when the API page allows inserts and the field's value must be supplied by the caller. Marking it read-only removes the field from the OData write schema, so a POST that includes it is rejected as an unknown property. This only applies to keys the consumer must supply β a system-generated key such as `SystemId` is a valid exception, since Business Central assigns its value automatically on insert.
+
+## Best Practice
+
+Leave every consumer-supplied key field referenced in `ODataKeyFields` without `Editable = false` on pages where `InsertAllowed = true`, so the OData layer accepts it as a writable property on POST.
+
+See sample: [`api-page-key-fields-must-be-editable-on-insert.good.al`](api-page-key-fields-must-be-editable-on-insert.good.al).
+
+## Anti Pattern
+
+Marking a consumer-provided key field `Editable = false`, out of habit or for perceived safety. This silently breaks create operations with a generic `BadRequest` instead of a clear validation error.
+
+See sample: [`api-page-key-fields-must-be-editable-on-insert.bad.al`](api-page-key-fields-must-be-editable-on-insert.bad.al).
diff --git a/microsoft/knowledge/web-services/api-page-least-privilege-write-access.bad.al b/microsoft/knowledge/web-services/api-page-least-privilege-write-access.bad.al
new file mode 100644
index 0000000..13c33b4
--- /dev/null
+++ b/microsoft/knowledge/web-services/api-page-least-privilege-write-access.bad.al
@@ -0,0 +1,25 @@
+page 50100 "Vendor Document API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'documents';
+ APIVersion = 'v1.0';
+ EntityName = 'vendorDocument';
+ EntitySetName = 'vendorDocuments';
+ SourceTable = Vendor;
+ // no InsertAllowed/ModifyAllowed override, no Editable = false anywhere
+
+ layout
+ {
+ area(content)
+ {
+ repeater(GroupName)
+ {
+ field(no; Rec."No.") { }
+ field(vatRegNo; Rec."VAT Registration No.") { }
+ field(contactEmail; Rec."E-Mail") { }
+ // ...dozens more fields, none marked Editable = false
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/api-page-least-privilege-write-access.good.al b/microsoft/knowledge/web-services/api-page-least-privilege-write-access.good.al
new file mode 100644
index 0000000..b524b4f
--- /dev/null
+++ b/microsoft/knowledge/web-services/api-page-least-privilege-write-access.good.al
@@ -0,0 +1,25 @@
+page 50102 "Vendor Contact Info API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'integration';
+ APIVersion = 'v1.0';
+ EntityName = 'vendorContact';
+ EntitySetName = 'vendorContacts';
+ SourceTable = Vendor;
+ DelayedInsert = true;
+ InsertAllowed = false;
+ DeleteAllowed = false;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(GroupName)
+ {
+ field(no; Rec."No.") { Editable = false; }
+ field(contactEmail; Rec."E-Mail") { }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/api-page-least-privilege-write-access.md b/microsoft/knowledge/web-services/api-page-least-privilege-write-access.md
new file mode 100644
index 0000000..6f40c87
--- /dev/null
+++ b/microsoft/knowledge/web-services/api-page-least-privilege-write-access.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: web-services
+keywords: [api-page, least-privilege, write-access, odata, security, external-api, identity-fields]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Give API pages least-privilege write access
+
+## Description
+
+A general-purpose API page that exposes many fields should not be widened to allow writes on one additional field. A `PageType = API` page consumed by an external integration, an automation agent, or a partner system carries the same risk regardless of caller: a write-enabled page with no per-field restriction is a wide-open surface. Least privilege has to cover both dimensions of exposure: which fields are on the page, and which operations the page allows. Only a field actually placed on the page is reachable at all β but a page that includes many fields, with `InsertAllowed`/`ModifyAllowed`/`DeleteAllowed` left at their defaults and no `Editable = false` on most of them, leaves every one of those included fields β identity fields and financially significant ones among them β fully writable, with nothing marking that as deliberate. Restricting fields alone is not enough either: a page with only two fields on it can still let a caller insert brand-new records or delete existing ones if `InsertAllowed`/`DeleteAllowed` are left at their true defaults (both `true`).
+
+## Best Practice
+
+Create a separate, minimal API page that exposes only the key and the specific field the consumer needs to write, with everything else `Editable = false` or simply absent from the page β and set `InsertAllowed`/`DeleteAllowed` to `false` unless the consumer's use case genuinely needs to create or delete records through that page.
+
+See sample: [`api-page-least-privilege-write-access.good.al`](api-page-least-privilege-write-access.good.al).
+
+## Anti Pattern
+
+Widening an existing general-purpose API page with write access to one field, leaving every other field on the page (including identity and posting fields) writable by default because no one added `Editable = false`.
+
+See sample: [`api-page-least-privilege-write-access.bad.al`](api-page-least-privilege-write-access.bad.al).
diff --git a/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.bad.al b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.bad.al
new file mode 100644
index 0000000..847045b
--- /dev/null
+++ b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.bad.al
@@ -0,0 +1,21 @@
+codeunit 50100 "HTTP Status Handling Bad"
+{
+ procedure GetCustomer(CustomerId: Guid): JsonObject
+ var
+ Client: HttpClient;
+ Response: HttpResponseMessage;
+ CustomerJson: JsonObject;
+ ResponseText: Text;
+ begin
+ if not Client.Get(
+ StrSubstNo('https://api.example.com/customers/%1', CustomerId),
+ Response)
+ then
+ Error('The customer service could not be reached.');
+
+ // A completed request can still contain a 4xx or 5xx error document.
+ Response.Content().ReadAs(ResponseText);
+ CustomerJson.ReadFrom(ResponseText);
+ exit(CustomerJson);
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.good.al b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.good.al
new file mode 100644
index 0000000..e15682b
--- /dev/null
+++ b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.good.al
@@ -0,0 +1,23 @@
+codeunit 50100 "HTTP Status Handling Good"
+{
+ procedure GetCustomer(CustomerId: Guid): JsonObject
+ var
+ Client: HttpClient;
+ Response: HttpResponseMessage;
+ CustomerJson: JsonObject;
+ ResponseText: Text;
+ begin
+ if not Client.Get(
+ StrSubstNo('https://api.example.com/customers/%1', CustomerId),
+ Response)
+ then
+ Error('The customer service could not be reached.');
+
+ if not Response.IsSuccessStatusCode() then
+ Error('The customer service returned HTTP status %1.', Response.HttpStatusCode());
+
+ Response.Content().ReadAs(ResponseText);
+ CustomerJson.ReadFrom(ResponseText);
+ exit(CustomerJson);
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.md b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.md
new file mode 100644
index 0000000..f924870
--- /dev/null
+++ b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.md
@@ -0,0 +1,31 @@
+---
+bc-version: [all]
+domain: web-services
+keywords: [httpclient, httpresponsemessage, issuccessstatuscode, httpstatuscode, response-body, json]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Check HTTP status before consuming the response body
+
+## Description
+
+A successful AL `HttpClient` call only confirms that the platform completed the HTTP exchange. The server can still return `4xx` or `5xx`, often with an error document whose shape differs from the expected success payload. Parsing that body as business data can produce misleading parse errors, incomplete records, or decisions based on an error response.
+
+## Best Practice
+
+After handling any platform or transport failure, check `HttpResponseMessage.IsSuccessStatusCode()` or the expected `HttpStatusCode()` before interpreting the response body as a success payload. Handle non-success status explicitly and include safe diagnostic context when appropriate. A bounded error body may be read for diagnostics, but it must not enter the success parsing path.
+
+See sample: [`check-http-status-before-consuming-response-body.good.al`](check-http-status-before-consuming-response-body.good.al).
+
+## Anti Pattern
+
+Checking only the Boolean result of `Get`, `Post`, `Put`, `Delete`, or `Send` and then parsing `Response.Content()` as the expected payload. The Boolean can be `true` for any HTTP status, including authentication failures, throttling, validation errors, and server failures.
+
+See sample: [`check-http-status-before-consuming-response-body.bad.al`](check-http-status-before-consuming-response-body.bad.al).
+
+## References
+
+- [HttpResponseMessage.IsSuccessStatusCode method](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/httpresponsemessage/httpresponsemessage-issuccessstatuscode-method)
+- [HttpClient data type](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/httpclient/httpclient-data-type)
\ No newline at end of file
diff --git a/microsoft/knowledge/web-services/check-json-null-before-converting-values.bad.al b/microsoft/knowledge/web-services/check-json-null-before-converting-values.bad.al
new file mode 100644
index 0000000..d52ebf9
--- /dev/null
+++ b/microsoft/knowledge/web-services/check-json-null-before-converting-values.bad.al
@@ -0,0 +1,11 @@
+codeunit 50173 "Contact Payload Reader Bad"
+{
+ procedure ApplyPayload(var Contact: Record Contact; Payload: JsonObject)
+ var
+ Token: JsonToken;
+ begin
+ if Payload.Get('email', Token) then
+ Contact.Validate("E-Mail", CopyStr(Token.AsValue().AsText(), 1, MaxStrLen(Contact."E-Mail")));
+ Contact.Modify(true);
+ end;
+}
diff --git a/microsoft/knowledge/web-services/check-json-null-before-converting-values.good.al b/microsoft/knowledge/web-services/check-json-null-before-converting-values.good.al
new file mode 100644
index 0000000..91f83d5
--- /dev/null
+++ b/microsoft/knowledge/web-services/check-json-null-before-converting-values.good.al
@@ -0,0 +1,28 @@
+codeunit 50172 "Contact Payload Reader Good"
+{
+ procedure ApplyPayload(var Contact: Record Contact; Payload: JsonObject)
+ var
+ EmailAddress: Text;
+ begin
+ if TryGetText(Payload, 'email', EmailAddress) then
+ Contact.Validate("E-Mail", CopyStr(EmailAddress, 1, MaxStrLen(Contact."E-Mail")));
+ Contact.Modify(true);
+ end;
+
+ local procedure TryGetText(Payload: JsonObject; PropertyName: Text; var Value: Text): Boolean
+ var
+ Token: JsonToken;
+ begin
+ if not Payload.Get(PropertyName, Token) then
+ exit(false);
+ if not Token.IsValue() then
+ Error(NotAValueErr, PropertyName);
+ if Token.AsValue().IsNull() then
+ exit(false);
+ Value := Token.AsValue().AsText();
+ exit(true);
+ end;
+
+ var
+ NotAValueErr: Label 'The property %1 must contain a single value.', Comment = '%1 = JSON property name';
+}
diff --git a/microsoft/knowledge/web-services/check-json-null-before-converting-values.md b/microsoft/knowledge/web-services/check-json-null-before-converting-values.md
new file mode 100644
index 0000000..b5a560e
--- /dev/null
+++ b/microsoft/knowledge/web-services/check-json-null-before-converting-values.md
@@ -0,0 +1,35 @@
+---
+bc-version: [all]
+domain: web-services
+keywords: [jsonobject, jsontoken, jsonvalue, isnull, asvalue, astext, asdecimal, optional-property, json-null, payload-parsing]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Check for JSON null before converting a value
+
+## Description
+
+An optional property in a JSON payload can be missing or present with the value `null`, and the two cases behave differently in AL. When the Boolean result is captured, `JsonObject.Get` returns `false` for a missing key but `true` for `"email": null`, because the key exists. The conversion methods on `JsonValue` (`AsText`, `AsCode`, `AsDecimal`, `AsInteger`, `AsDate`, `AsBoolean`, and the others) fail with a runtime error when the value is `NULL` or `UNDEFINED`. Code that guards only with `Get` therefore passes its tests with the property omitted and fails in production when the sender serializes an empty field as `null`, which many services do by default.
+
+## Best Practice
+
+For every property that the contract allows to be optional or nullable, check three things before converting: that `Get` (or `SelectToken`) returned `true`, that the token `IsValue()` rather than an object or array, and that `AsValue().IsNull()` is `false`. Put this in one small helper per target type and decide explicitly what a missing or null property means: a default, leaving the field unchanged, or a validation error that names the property.
+
+Required properties can still fail fast, but with an error that states the missing or null property instead of a generic conversion error. Keep a numeric or date conversion strict when the contract says the value must be a number or a date; `IsNull` covers only `null`, not a value of the wrong type.
+
+See sample: [`check-json-null-before-converting-values.good.al`](check-json-null-before-converting-values.good.al).
+
+## Anti Pattern
+
+`if Json.Get('email', Token) then Email := Token.AsValue().AsText();` or an unguarded `Token.AsValue().AsDecimal()` on a property that the external contract allows to be `null`. The `Get` check makes the code look defensive, but it doesn't handle a present `null`. Detection signal: an `As()` call on a `JsonValue` obtained from an external payload with no preceding `IsNull()` check on the same token, where the property isn't documented as always non-null.
+
+See sample: [`check-json-null-before-converting-values.bad.al`](check-json-null-before-converting-values.bad.al).
+
+## References
+
+- [JsonObject.Get method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/jsonobject/jsonobject-get-method)
+- [JsonValue.AsText method: fails on NULL or UNDEFINED](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/jsonvalue/jsonvalue-astext-method)
+- [JsonValue.IsNull method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/jsonvalue/jsonvalue-isnull-method)
+- [JsonToken data type](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/jsontoken/jsontoken-data-type)
diff --git a/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md b/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md
index 2358a6b..8f6a73c 100644
--- a/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md
+++ b/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md
@@ -17,10 +17,10 @@ An API meant purely for reading β a reporting or lookup endpoint β is not re
For a read-only / reporting API page set all three CRUD guards off β `InsertAllowed = false`, `ModifyAllowed = false`, `DeleteAllowed = false` β and mark the page `Editable = false`. The endpoint then serves GET requests and rejects any insert, modify, or delete, matching the read-only contract regardless of the caller. Make the read-only stance explicit rather than depending on the writable default.
-See sample: `disable-write-operations-on-read-only-api-pages.good.al`.
+See sample: [`disable-write-operations-on-read-only-api-pages.good.al`](disable-write-operations-on-read-only-api-pages.good.al).
## Anti Pattern
An API intended for read-only consumption that omits the CRUD guards, leaving `InsertAllowed`, `ModifyAllowed`, and `DeleteAllowed` at their writable defaults. The endpoint silently accepts POST, PATCH, and DELETE, so a client can mutate or remove data the API was never meant to expose for writing. The detection signal: a read-only/reporting `PageType = API` page that does not set the three `*Allowed = false` properties.
-See sample: `disable-write-operations-on-read-only-api-pages.bad.al`.
+See sample: [`disable-write-operations-on-read-only-api-pages.bad.al`](disable-write-operations-on-read-only-api-pages.bad.al).
diff --git a/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md b/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md
index 739b5aa..4337b41 100644
--- a/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md
+++ b/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md
@@ -17,10 +17,10 @@ This is about the data-consistency contract of an API endpoint: what a consumer
For an API page that must expose only committed data, set the endpoint's read isolation once as the page opens: in the `OnOpenPage` trigger write `Rec.ReadIsolation := IsolationLevel::ReadCommitted;`. Every read the endpoint then serves ignores uncommitted writes from concurrent transactions, so a consumer never receives a row that another transaction might still roll back.
-See sample: `expose-only-committed-data-from-api-reads.good.al`.
+See sample: [`expose-only-committed-data-from-api-reads.good.al`](expose-only-committed-data-from-api-reads.good.al).
## Anti Pattern
An API intended to return committed-only data that sets no isolation level, leaving reads at the default that can observe in-flight, uncommitted writes. A consumer can fetch a row created by a concurrent transaction that is later rolled back β a dirty read that surfaces data which never durably existed. The detection signal: a committed-only read API with no `Rec.ReadIsolation := IsolationLevel::ReadCommitted` in `OnOpenPage`.
-See sample: `expose-only-committed-data-from-api-reads.bad.al`.
+See sample: [`expose-only-committed-data-from-api-reads.bad.al`](expose-only-committed-data-from-api-reads.bad.al).
diff --git a/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md b/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md
index 7f0ed87..18908a7 100644
--- a/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md
+++ b/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md
@@ -17,10 +17,10 @@ An API consumer that needs to *do* something to a record β post it, ship it, r
Declare the operation as `[ServiceEnabled] procedure Post(var ActionContext: WebServiceActionContext)` on the API page. Inside, perform the operation against `Rec`, then call a `SetActionResponse` helper that writes the result β the bound record and its id β back into the `WebServiceActionContext` so the caller receives a well-formed response. The operation is now an explicit, named endpoint action separate from ordinary field writes.
-See sample: `expose-operations-as-bound-actions.good.al`.
+See sample: [`expose-operations-as-bound-actions.good.al`](expose-operations-as-bound-actions.good.al).
## Anti Pattern
Exposing a writable Boolean (for example `posted`) whose `OnValidate` performs the posting. A client that PATCHes the field to `true` β an action indistinguishable from any other data edit β silently triggers a side-effecting business operation. The detection signal: an API page field whose `OnValidate` posts, ships, or releases, instead of a `[ServiceEnabled]` bound action.
-See sample: `expose-operations-as-bound-actions.bad.al`.
+See sample: [`expose-operations-as-bound-actions.bad.al`](expose-operations-as-bound-actions.bad.al).
diff --git a/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md b/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md
index 93d2dcd..f34e9a4 100644
--- a/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md
+++ b/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md
@@ -17,10 +17,10 @@ Every BC table carries a `SystemId` β an immutable GUID assigned at insert and
Set `ODataKeyFields = SystemId` so OData routes records by the stable GUID, and expose it as `field(id; Rec.SystemId)` marked `Editable = false`. Clients then address a record at `.../customers()`, an identity that survives any rename of the business key. Keep the business key (for example `No.`) as an ordinary exposed field, not as the OData key.
-See sample: `expose-systemid-as-the-api-key.good.al`.
+See sample: [`expose-systemid-as-the-api-key.good.al`](expose-systemid-as-the-api-key.good.al).
## Anti Pattern
Setting `ODataKeyFields = "No."` so the endpoint addresses records by a renamable business field. As soon as a user changes that `No.`, every external reference built on the old value points at nothing, silently breaking integrations. The detection signal: `ODataKeyFields` set to a business field rather than `SystemId`, or an API page that exposes no `id` field bound to `Rec.SystemId`.
-See sample: `expose-systemid-as-the-api-key.bad.al`.
+See sample: [`expose-systemid-as-the-api-key.bad.al`](expose-systemid-as-the-api-key.bad.al).
diff --git a/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.bad.al b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.bad.al
new file mode 100644
index 0000000..3e36c87
--- /dev/null
+++ b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.bad.al
@@ -0,0 +1,27 @@
+codeunit 50171 "Exchange Rate Export Bad"
+{
+ procedure SendRate(CurrencyCode: Code[10]; StartingDate: Date; ExchangeRate: Decimal)
+ var
+ Client: HttpClient;
+ Response: HttpResponseMessage;
+ RequestUrl: Text;
+ begin
+ RequestUrl := StrSubstNo(RateUrlTok, CurrencyCode, Format(StartingDate), Format(ExchangeRate));
+ if not Client.Get(RequestUrl, Response) then
+ Error(RequestFailedErr);
+ if not Response.IsSuccessStatusCode() then
+ Error(RateRejectedErr, Response.HttpStatusCode());
+ end;
+
+ procedure ReadRate(RateText: Text) ExchangeRate: Decimal
+ begin
+ if not Evaluate(ExchangeRate, RateText) then
+ Error(InvalidRateErr, RateText);
+ end;
+
+ var
+ RateUrlTok: Label 'https://rates.example.com/rates?currency=%1&date=%2&rate=%3', Locked = true;
+ RequestFailedErr: Label 'The exchange rate service could not be reached.';
+ RateRejectedErr: Label 'The exchange rate service rejected the rate. Status code: %1.', Comment = '%1 = HTTP status code';
+ InvalidRateErr: Label 'The exchange rate %1 is not a valid decimal number.', Comment = '%1 = received value';
+}
diff --git a/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.good.al b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.good.al
new file mode 100644
index 0000000..21d6a22
--- /dev/null
+++ b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.good.al
@@ -0,0 +1,27 @@
+codeunit 50170 "Exchange Rate Export Good"
+{
+ procedure SendRate(CurrencyCode: Code[10]; StartingDate: Date; ExchangeRate: Decimal)
+ var
+ Client: HttpClient;
+ Response: HttpResponseMessage;
+ RequestUrl: Text;
+ begin
+ RequestUrl := StrSubstNo(RateUrlTok, CurrencyCode, Format(StartingDate, 0, 9), Format(ExchangeRate, 0, 9));
+ if not Client.Get(RequestUrl, Response) then
+ Error(RequestFailedErr);
+ if not Response.IsSuccessStatusCode() then
+ Error(RateRejectedErr, Response.HttpStatusCode());
+ end;
+
+ procedure ReadRate(RateText: Text) ExchangeRate: Decimal
+ begin
+ if not Evaluate(ExchangeRate, RateText, 9) then
+ Error(InvalidRateErr, RateText);
+ end;
+
+ var
+ RateUrlTok: Label 'https://rates.example.com/rates?currency=%1&date=%2&rate=%3', Locked = true;
+ RequestFailedErr: Label 'The exchange rate service could not be reached.';
+ RateRejectedErr: Label 'The exchange rate service rejected the rate. Status code: %1.', Comment = '%1 = HTTP status code';
+ InvalidRateErr: Label 'The exchange rate %1 is not a valid decimal number.', Comment = '%1 = received value';
+}
diff --git a/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.md b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.md
new file mode 100644
index 0000000..adc1dae
--- /dev/null
+++ b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.md
@@ -0,0 +1,39 @@
+---
+bc-version: [all]
+domain: web-services
+keywords: [format, evaluate, standard-format-9, xml-format, locale, regional-settings, decimal-separator, data-exchange, integration]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Format exchanged values with standard format 9
+
+## Description
+
+`Format(Value)` uses standard format 0, the display format, and follows the current user's regional settings. The same decimal renders as `-76.543,21` for a European region and `-76,543.21` for English (US); the same date renders as `05-04-21` or `04/05/21`. Text built this way and sent outside Business Central (an HTTP query string or body, an XML or CSV file, a signature or hash input, or an external key) changes with the user or job queue session that produces it. The receiver can reject it or, worse, misread it. `Evaluate` without a format number has the same dependency when it parses machine-generated text.
+
+## Best Practice
+
+Use `Format(Value, 0, 9)` for machine-readable text. Standard format 9 is the XML format and doesn't depend on the region: `-76543.21` for a decimal, `2021-04-05` for a date, `04:35:55.553` for a time, `true`/`false` for a Boolean, and a UTC `DateTime` such as `2021-04-05T03:35:55.553Z`. Parse such text with `Evaluate(Variable, Text, 9)`.
+
+Prefer typed APIs when they exist. `JsonObject.Add` and `JsonValue.SetValue` with a `Decimal`, `Date`, or `Boolean` argument write a JSON value without going through display text. An XMLport handles this with `FormatEvaluate = Xml`.
+
+For `Enum` and `Option` values, format 9 produces the ordinal number, not the name. When the external contract exchanges names, map them explicitly; see [`api-enum-values-are-a-contract-by-name-not-ordinal.md`](api-enum-values-are-a-contract-by-name-not-ordinal.md).
+
+Text shown to a person (messages, captions, report columns, notifications) should keep the regional display format. `Code`, `Text`, and `Guid` values don't need format 9 because their standard formats don't vary by region.
+
+See sample: [`format-exchanged-values-with-standard-format-9.good.al`](format-exchanged-values-with-standard-format-9.good.al).
+
+## Anti Pattern
+
+`Format(Amount)`, `Format(PostingDate)`, or `Format(SomeDateTime)` concatenated into a URL, request body, XML or CSV line, file name, or hash input. Passing the `Decimal` or `Date` itself to `StrSubstNo` for such text has the same effect, because `StrSubstNo` formats it with the display format. Also `Evaluate(DecimalOrDateVariable, ExternalText)` without format number 9 on text received from another system. The code usually works for the developer's own region and fails for users or job queue sessions in another one. Detection signal: `Format` with one argument, or with a format number other than 9, applied to a `Decimal`, `Date`, `Time`, `DateTime`, or `Boolean` on a path that writes to an `HttpContent`, `HttpRequestMessage`, `OutStream`, `XmlDocument`, or file.
+
+See sample: [`format-exchanged-values-with-standard-format-9.bad.al`](format-exchanged-values-with-standard-format-9.bad.al).
+
+## References
+
+- [Formatting values, dates, and time: standard formats by region and format 9](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-format-property)
+- [System.Format method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/system/system-format-joker-integer-integer-method)
+- [System.Evaluate method and format number 9](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/system/system-evaluate-method)
+- [FormatEvaluate property for XMLports](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-formatevaluate-property)
diff --git a/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.bad.al b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.bad.al
new file mode 100644
index 0000000..8ac6d6a
--- /dev/null
+++ b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.bad.al
@@ -0,0 +1,18 @@
+codeunit 50100 "Http Platform Failure Bad"
+{
+ procedure GetCustomer(CustomerId: Guid): Text
+ var
+ Client: HttpClient;
+ Response: HttpResponseMessage;
+ ResponseText: Text;
+ RequestSucceeded: Boolean;
+ begin
+ RequestSucceeded := Client.Get(
+ StrSubstNo('https://api.example.com/customers/%1', CustomerId),
+ Response);
+
+ // Response content is unavailable when the platform call failed.
+ Response.Content().ReadAs(ResponseText);
+ exit(ResponseText);
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.good.al b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.good.al
new file mode 100644
index 0000000..019f0be
--- /dev/null
+++ b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.good.al
@@ -0,0 +1,18 @@
+codeunit 50100 "Http Platform Failure Good"
+{
+ procedure GetCustomer(CustomerId: Guid): Text
+ var
+ Client: HttpClient;
+ Response: HttpResponseMessage;
+ ResponseText: Text;
+ begin
+ if not Client.Get(
+ StrSubstNo('https://api.example.com/customers/%1', CustomerId),
+ Response)
+ then
+ Error('The customer service could not be reached.');
+
+ Response.Content().ReadAs(ResponseText);
+ exit(ResponseText);
+ end;
+}
\ No newline at end of file
diff --git a/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.md b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.md
new file mode 100644
index 0000000..e3dd227
--- /dev/null
+++ b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.md
@@ -0,0 +1,31 @@
+---
+bc-version: [all]
+domain: web-services
+keywords: [httpclient, transport-failure, boolean-return, httpresponsemessage, content, runtime-error]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Handle HttpClient platform failure before accessing the response
+
+## Description
+
+AL `HttpClient` methods can fail before a usable HTTP response exists because of an invalid request, DNS or network failure, certificate validation, timeout, a disabled extension setting, or the response-size limit. When code captures the optional Boolean return value, `false` reports this platform or transport failure. The accompanying `HttpResponseMessage` is not safe to consume; accessing its content after the failed call can raise another error and obscure the original failure.
+
+## Best Practice
+
+When capturing the Boolean return value from `Get`, `Post`, `Put`, `Delete`, or `Send`, stop the current response-processing path immediately when it is `false`. Report or propagate the transport failure without reading status, headers, or content. Omitting the optional Boolean is also valid when fail-fast behavior is intended: the runtime then raises an error if the operation cannot execute.
+
+See sample: [`handle-httpclient-platform-failure-before-response-access.good.al`](handle-httpclient-platform-failure-before-response-access.good.al).
+
+## Anti Pattern
+
+Capturing a failed call in a Boolean and then reading `Response.Content()`, parsing the body, or otherwise treating `Response` as usable. Do not report omission of the Boolean by itself; that form deliberately delegates failure propagation to the runtime.
+
+See sample: [`handle-httpclient-platform-failure-before-response-access.bad.al`](handle-httpclient-platform-failure-before-response-access.bad.al).
+
+## References
+
+- [HttpClient.Send method](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/httpclient/httpclient-send-method)
+- [Call external services with HttpClient](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-httpclient)
\ No newline at end of file
diff --git a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md
index 4cf81d6..2f92c0c 100644
--- a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md
+++ b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md
@@ -17,13 +17,13 @@ application-area: [all]
Define the child foreign key as `Guid` with a `TableRelation` to the parent table's `SystemId`, then use `SubPageLink = "" = Field(SystemId)` on the parent API page. A child collection may omit `Multiplicity` and rely on the default 1:N relationship, or declare `Multiplicity = Many` explicitly. Set `Multiplicity = ZeroOrOne` when the intended navigation metadata is a singleton.
-See sample: `link-api-parts-on-systemid-and-set-multiplicity.good.al`.
+See sample: [`link-api-parts-on-systemid-and-set-multiplicity.good.al`](link-api-parts-on-systemid-and-set-multiplicity.good.al).
## Anti Pattern
On a parent API with `ODataKeyFields = SystemId`, linking a child business field such as `"Order No."` to the parent's `"No."` creates a second identity scheme for navigation instead of using the contract's stable GUID. A separate defect is an explicit `Multiplicity` that conflicts with the intended shape, such as `ZeroOrOne` on an order-lines collection or `Many` on a singleton. Do not treat omission alone as a defect: it is valid for a collection because the default is 1:N, while an intended singleton must explicitly use `Multiplicity = ZeroOrOne`.
-See sample: `link-api-parts-on-systemid-and-set-multiplicity.bad.al`.
+See sample: [`link-api-parts-on-systemid-and-set-multiplicity.bad.al`](link-api-parts-on-systemid-and-set-multiplicity.bad.al).
## Source
diff --git a/microsoft/knowledge/web-services/set-required-api-page-properties.md b/microsoft/knowledge/web-services/set-required-api-page-properties.md
index 496db1a..24edc5d 100644
--- a/microsoft/knowledge/web-services/set-required-api-page-properties.md
+++ b/microsoft/knowledge/web-services/set-required-api-page-properties.md
@@ -17,10 +17,10 @@ An API page needs `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, and
Declare the five routing/entity properties required by the API page and set `APIVersion` explicitly for a stable published contract, for example `'v1.0'`. Expose the record's fields inside a repeater under `area(content)`. Review missing routing metadata as a malformed API definition, but review a missing `APIVersion` as unintended publication under `beta`, not as an unpublished endpoint.
-See sample: `set-required-api-page-properties.good.al`.
+See sample: [`set-required-api-page-properties.good.al`](set-required-api-page-properties.good.al).
## Anti Pattern
Leaving out `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, or `SourceTable` leaves the API definition incomplete. A subtler contract defect is declaring all of those but omitting `APIVersion`: the page is exposed as `beta`, which is valid runtime behavior but not the explicit stable route a production client expects.
-See sample: `set-required-api-page-properties.bad.al`.
+See sample: [`set-required-api-page-properties.bad.al`](set-required-api-page-properties.bad.al).
diff --git a/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.bad.al b/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.bad.al
new file mode 100644
index 0000000..9938612
--- /dev/null
+++ b/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.bad.al
@@ -0,0 +1,24 @@
+page 50102 "Project Task API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'jobs';
+ APIVersion = 'v1.0';
+ EntityName = 'projectTask';
+ EntitySetName = 'projectTasks';
+ SourceTable = "Project Task";
+
+ layout
+ {
+ area(content)
+ {
+ repeater(General)
+ {
+ // "Remaining Hours" is a stored field, set inside the
+ // OnValidate of "Budgeted Hours" β it goes stale whenever
+ // "Hours Used" changes through any other path.
+ field(remainingHours; Rec."Remaining Hours") { }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.good.al b/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.good.al
new file mode 100644
index 0000000..1ab78db
--- /dev/null
+++ b/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.good.al
@@ -0,0 +1,33 @@
+page 50102 "Project Task API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'jobs';
+ APIVersion = 'v1.0';
+ EntityName = 'projectTask';
+ EntitySetName = 'projectTasks';
+ SourceTable = "Project Task";
+ DelayedInsert = true;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(General)
+ {
+ field(remainingHours; RemainingHoursCalc) { }
+ field(budgetedHours; Rec."Budgeted Hours") { }
+ field(hoursUsed; Rec."Hours Used") { }
+ }
+ }
+ }
+
+ trigger OnAfterGetRecord()
+ begin
+ Rec.CalcFields("Hours Used");
+ RemainingHoursCalc := Rec."Budgeted Hours" - Rec."Hours Used";
+ end;
+
+ var
+ RemainingHoursCalc: Decimal;
+}
diff --git a/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.md b/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.md
new file mode 100644
index 0000000..2d02f93
--- /dev/null
+++ b/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: web-services
+keywords: [api-page, derived-fields, exposure, odata]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Recalculate Stored Derived Fields Before Exposing Them on API Pages
+
+> Contributions welcome β open a PR to refine or extend this article.
+
+## Description
+
+A stored field whose value is derived from other fields inside an `OnValidate` trigger only updates when that specific trigger fires. If the underlying source data changes through some other path, the stored value goes stale without raising any error. Exposing such a field directly on an API page hands external consumers a snapshot that may be significantly out of date.
+
+## Best Practice
+
+Recalculate the derived value in `OnAfterGetRecord` from its authoritative source β typically a FlowField β using a page-level variable, and expose that recalculated value instead of the stale stored field. Whether to also expose the source fields is a separate design decision, not a requirement of this pattern; keep the API contract scoped to what consumers actually need. If letting the consumer verify the recalculation is itself a requirement, expose every field the calculation reads, not just one of them β a derived value with two inputs needs both exposed, or the "verification" is incomplete.
+
+See sample: [`stored-derived-fields-must-not-be-exposed-directly.good.al`](stored-derived-fields-must-not-be-exposed-directly.good.al).
+
+## Anti Pattern
+
+Exposing the stored field directly via `Rec`, trusting that it was kept in sync by whichever trigger last touched it.
+
+See sample: [`stored-derived-fields-must-not-be-exposed-directly.bad.al`](stored-derived-fields-must-not-be-exposed-directly.bad.al).
diff --git a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md
index bfd2c9f..1e8417b 100644
--- a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md
+++ b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md
@@ -17,10 +17,10 @@ Once an API version is published, external clients depend on its exact shape β
Keep the existing page object and its `APIVersion = 'v1.0'` contract unchanged. Copy the page to a new object ID, set that object's `APIVersion = 'v2.0'`, and make the v2-only shape changes there. A multi-value `APIVersion` list is appropriate only when the exact same page shape is supported under each listed version.
-See sample: `version-apis-by-adding-not-mutating-published-versions.good.al`.
+See sample: [`version-apis-by-adding-not-mutating-published-versions.good.al`](version-apis-by-adding-not-mutating-published-versions.good.al).
## Anti Pattern
Editing the published `v1.0` page in place breaks its clients. So does adding `v2.0` to that same page and assuming subsequent field changes apply only to v2: both routes use one object shape. The detection signal is a breaking shape change without a separate API page object retaining the old version.
-See sample: `version-apis-by-adding-not-mutating-published-versions.bad.al`.
+See sample: [`version-apis-by-adding-not-mutating-published-versions.bad.al`](version-apis-by-adding-not-mutating-published-versions.bad.al).
diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md
index b35a05c..2aad620 100644
--- a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md
+++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md
@@ -17,13 +17,13 @@ Business Central can subscribe only to eligible API pages, not every endpoint th
Before creating a subscription, confirm the resource appears in `webhookSupportedResources` and that a custom endpoint is an API page with a single stable key over an eligible persistent table. Use one validation path that echoes `validationToken` for both create (`POST`) and renew (`PATCH`) handshakes. Track `expirationDateTime` and renew before expiry: online subscriptions expire after three days, while on-premises lifetime defaults to three days and can be changed with `ApiSubscriptionExpiration`.
-See samples: `webhook-eligibility-and-validationtoken-renewal.good.al` and `webhook-eligibility-and-validationtoken-renewal.good.js`.
+See samples: [`webhook-eligibility-and-validationtoken-renewal.good.al`](webhook-eligibility-and-validationtoken-renewal.good.al) and [`webhook-eligibility-and-validationtoken-renewal.good.js`](webhook-eligibility-and-validationtoken-renewal.good.js).
## Anti Pattern
Attempting to subscribe to an API query, temporary/composite/system-table/Job Queue Entry API page, or assuming a successful create handshake makes renewal automatic. Composite includes an explicit multi-field `ODataKeyFields` and a missing `ODataKeyFields` when the source table's primary key has multiple fields. A renewal issues the same validation challenge; a notification handler that ignores the query-string token cannot create or renew the subscription.
-See samples: `webhook-eligibility-and-validationtoken-renewal.bad.al` and `webhook-eligibility-and-validationtoken-renewal.bad.js`.
+See samples: [`webhook-eligibility-and-validationtoken-renewal.bad.al`](webhook-eligibility-and-validationtoken-renewal.bad.al) and [`webhook-eligibility-and-validationtoken-renewal.bad.js`](webhook-eligibility-and-validationtoken-renewal.bad.js).
## Source
diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md
index 43a2e20..c12815f 100644
--- a/microsoft/skills/review/al-appsource-review.md
+++ b/microsoft/skills/review/al-appsource-review.md
@@ -4,7 +4,7 @@ id: al-appsource-review
version: 1
title: AL AppSource review
description: Performs an AL AppSource review against source and app metadata guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source and app metadata changes against the `appsource` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). AppSource findings are narrow by design β they apply when the diff touches AppSourceCop configuration, AL object or extension-member names, or AppSource-facing `app.json` metadata. The skill returns `not-applicable` when none of those apply.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. AppSource findings are narrow by design β they apply to Marketplace-facing metadata, complete permission coverage that requires repository context, and contextual AL constructs covered by Marketplace submission requirements. Mechanical compiler and analyzer diagnostics are intentionally outside this skill. The skill returns `not-applicable` when none of those surfaces apply.
## Source
-Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `appsource` as this skill's candidate set across every enabled Microsoft, community, and custom layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/appsource/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain appsource`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -37,20 +37,23 @@ Discard files that are not applicable. Retain conditionally applicable files (an
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
-- The changed files and AL object types β especially `app.json`, `AppSourceCop.json`, namespace declarations, permission-set objects, new objects, and table/page/report extensions that add fields, keys, controls, or actions to base objects.
-- The changed object and member names, weighted toward prefix/suffix consistency with `mandatoryAffixes` or `mandatoryPrefix`, plus AppSource-facing help metadata.
-- Tokens extracted from the diff that relate to AppSource (`AppSourceCop`, `mandatoryAffixes`, `mandatoryPrefix`, `AS0011`, `prefix`, `suffix`, `namespace`, `using`, `permissionset`, `Assignable`, `Permissions`, `SUPER`, `tableextension`, `pageextension`, `reportextension`, `field`, `key`, `control`, `action`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`).
+- The changed files and AL object types β especially `app.json`, permission-set and profile objects, setup and usage entry points, service-enabled procedures, user-facing pages and reports, and AppSource-facing help metadata.
+- Tokens extracted from the diff that relate to AppSource (`permissionset`, `Assignable`, `Permissions`, `SUPER`, `tabledata`, `execute`, `profile`, `Record Profile`, `Evaluate`, `Date`, `DateTime`, `CurrentDateTime`, `UsageCategory`, `PageType`, `addfirst`, `addlast`, `addbefore`, `addafter`, `ServiceEnabled`, `GuiAllowed`, `Message`, `Confirm`, `StrMenu`, `RunModal`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone. When the diff contains no AppSource-related source or metadata changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.
The following targeted checks cover every current `appsource` article across the Microsoft and community layers. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action.
-- 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.
+- Install, upgrade, or setup code provisions an app-owned profile through `Record Profile` and `Insert` instead of declaring a `profile` object β `define-profiles-as-al-objects`.
+- A hard-coded or label-backed formatted string is converted to `Date` with `Evaluate` β `use-invariant-date-literals`. Do not select this article for variable external input whose format must be validated at runtime.
+- A page extension uses `addbefore` or `addafter` to place a newly added action relative to a specific action owned by another app β `place-page-extension-actions-with-addfirst-or-addlast`. Do not flag those keywords in layouts or placement relative to an action owned by the same extension.
+- A page or codeunit web-service entry point, including a `[ServiceEnabled]` procedure, contains or reaches `Message`, `Confirm`, `StrMenu`, `Page.RunModal`, or a confirmation-dialog page without an effective non-GUI guard β `keep-web-service-paths-free-of-ui-calls`. Treat `Message` as suppressed and logged, making it ineffective as a service response; treat the other UI calls as callback-failure risks. Do not treat a controlled `Error` as interactive UI solely because it returns a service fault.
+- A page or report that repository context identifies as a direct user entry point omits `UsageCategory` or sets it to `None` β `set-usagecategory-on-searchable-entry-points`. Do not select this article based only on object type; exclude supporting parts, dialogs, API pages, and objects intentionally reached through another page.
+- A `DateTime` assignment adds or subtracts a fixed duration to represent an assumed regional offset β `do-not-hard-code-time-zone-offsets`. Require contextual evidence such as an hour-sized constant, offset-oriented name, or time-zone comment; do not flag deadlines, schedules, or elapsed-time calculations.
- For BC v27 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent β `keep-copilot-help-url-to-two-path-levels`.
+- A release/submission pipeline change (`AL-Go-Settings.json`, a publish/release workflow) or an `app.json` version bump is present without the new complete version being strictly greater than the previously submitted one, or the change asserts a hand-edited build/revision or every-merge-is-a-release policy as a universal AppSource rule rather than a project-specific workflow choice β `release-must-update-app-version`. Require repository/pipeline context to know the previously submitted version; a single `app.json` diff cannot prove ordering on its own.
+- Changed code declares or calls `File.Open`/`File.Create`/`File.Read`/`File.Write` in an app targeting Business Central Online β `file-datatype-saas`.
Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.
@@ -66,13 +69,13 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice`
Set `confidence` to:
-- `high` when the detection is based on an unambiguous pattern match (affix configuration/name or URL path depth).
+- `high` when the detection is based on an unambiguous pattern match such as URL path depth.
- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`.
- `low` when the finding is an advisory derived only from applicability.
After evaluating each worklist entry, also consider whether the diff exhibits an AppSource defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain β emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material AppSource defect a knowledgeable BC reviewer would agree is wrong β steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly AppSource; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate β if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
-For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add the configured affix to one object or extension member, or replace a deep help URL with a known two-level canonical URL). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement β no diff markers, no fences, no commentary β that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example, replacing a deep help URL with a known two-level canonical URL). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement β no diff markers, no fences, no commentary β that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
@@ -80,7 +83,7 @@ Outcome selection:
- `completed` β the skill evaluated every worklist item.
- `no-knowledge` β no applicable AppSource knowledge survived filtering.
-- `not-applicable` β the diff touches no AppSource source, analyzer configuration, or app-metadata surface.
+- `not-applicable` β the diff touches no Marketplace-related source, permission, or app-metadata surface.
- `partial` β a budget was hit before the worklist was exhausted.
- `failed` β an unrecoverable error occurred.
@@ -98,19 +101,19 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
},
"findings": [
{
- "id": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md",
+ "id": "microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md",
"severity": "major",
- "message": "The tableextension adds an unaffixed Loyalty Points field to Customer, so it violates the configured AppSource affix and can collide with another extension.",
+ "message": "The app help URL is deeper than two path levels, so Copilot truncates it and may ground answers on unrelated sibling documentation.",
"location": {
- "file": "src/CustomerExt.TableExt.al",
- "line": 8
+ "file": "app.json",
+ "line": 12
},
"references": [
- { "path": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md" }
+ { "path": "microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md" }
],
"confidence": "high",
"domain": "AppSource",
- "suggested-code": "field(50100; \"Loyalty Points ABC\"; Integer)"
+ "suggested-code": "\"help\": \"https://contoso.com/docs/myapp\""
}
],
"suppressed": []
diff --git a/microsoft/skills/review/al-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md
index 82aeda0..5e2f3aa 100644
--- a/microsoft/skills/review/al-breaking-changes-review.md
+++ b/microsoft/skills/review/al-breaking-changes-review.md
@@ -4,7 +4,7 @@ id: al-breaking-changes-review
version: 1
title: AL breaking changes review
description: Reviews AL source changes against breaking-changes guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `breaking-changes` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `breaking-changes` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/breaking-changes/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain breaking-changes`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review
- The changed AL object names and types β especially codeunits, tables, and table extensions that expose procedures, fields, or events to other apps, and any member whose access is being widened.
- The changed procedures, fields, and triggers, weighted toward non-`local` procedures, published table fields, event publishers, and any member whose signature, access modifier, or obsolete state is being altered.
-- Tokens extracted from the diff that relate to API stability and deprecation (`signature`, `parameter`, `return`, `var`, `Obsolete`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `Pending`, `Removed`, `CLEAN`, `SecretText`, `token`, `internal`, `local`, `public`, `protected`, `Scope`, `namespace`, `using`, `AS0007`).
+- Tokens extracted from the diff that relate to API stability and deprecation (`signature`, `parameter`, `return`, `var`, `Obsolete`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `Pending`, `Removed`, `CLEAN`, `SecretText`, `token`, `internal`, `local`, `public`, `protected`, `Scope`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone.
@@ -52,6 +52,7 @@ The following targeted checks cover every current `breaking-changes` article:
- 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.
+- New or changed code calls `Codeunit Mail`'s `CreateMessage`/`Send`/`GetErrorDesc` instead of `Codeunit Email`/`Codeunit "Email Message"` β `prefer-email-module`.
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.
@@ -134,7 +135,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
}
```
-The empty-corpus case β BCQuality's state until breaking-changes knowledge files land β produces:
+When no applicable breaking-changes knowledge is available, the report is:
```json
{
diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md
index 9b3f934..30bf659 100644
--- a/microsoft/skills/review/al-code-review.md
+++ b/microsoft/skills/review/al-code-review.md
@@ -4,7 +4,7 @@ id: al-code-review
version: 1
title: AL code review
description: Reviews AL source changes by composing the AL review leaf skills, one per knowledge domain.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -25,8 +25,11 @@ sub-skills:
- microsoft/skills/review/al-testing-review.md
- microsoft/skills/review/al-data-modeling-review.md
- microsoft/skills/review/al-query-review.md
+ - microsoft/skills/review/al-reporting-review.md
- microsoft/skills/review/al-appsource-review.md
- microsoft/skills/review/al-telemetry-review.md
+ - microsoft/skills/review/al-scm-review.md
+ - microsoft/skills/review/al-finance-review.md
---
# AL code review
@@ -35,12 +38,21 @@ Reviews AL source changes by composing the leaf AL review skills. This is the ca
`al-code-review` does not evaluate knowledge files directly. It invokes each of its sub-skills against the same task input, collects their findings-reports, and then performs its own **self-review pass** over the diff using the agent's built-in BC and AL knowledge. BCQuality knowledge is an additive layer: anything the sub-skills found is cited from BCQuality, and anything the agent finds on its own is validated against BCQuality (cited if matched, suppressed if contradicted, surfaced as an **agent finding** otherwise). The result is a single rolled-up findings-report that mixes knowledge-backed and agent findings, each clearly tagged via `from-sub-skill`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract, extended with `sub-results` and β when applicable β `skipped-sub-skills`.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract, extended with `sub-results` and β when applicable β `skipped-sub-skills`.
## Source
The sub-skills invoked by this skill are those listed in frontmatter `sub-skills`. Additional leaf skills are added by updating the `sub-skills` list. The skill does not discover sub-skills implicitly.
+Hosts that orchestrate leaves mechanically SHOULD run
+`tools/Build-SkillIndex.ps1` and resolve this skill by `id: al-code-review`.
+The generated `subSkills` array preserves the frontmatter slot order and
+avoids host-specific Markdown parsing. Before invoking leaves, run
+`tools/Resolve-SkillWorklist.ps1` with this skill's path and the task's enabled
+layers and disabled skill paths. Each declared path supplies a leaf `id`; the
+resolver selects the highest-precedence enabled implementation with that `id`
+without changing slot order.
+
## Relevance
A sub-skill is relevant when both of the following hold:
@@ -59,26 +71,45 @@ Sub-skills that fail either check are not invoked and are recorded in `skipped-s
The worklist is the list of sub-skills judged relevant by the previous step. Every sub-skill in the worklist will be invoked in the Action step.
+Before dispatch, preserve that selection in a private host-owned expected
+composition artifact using DO's input contract. Start from the resolver's
+ordered selection; enrich configuration skips with the declared indexed
+skill's version, and record any input-incompatible exclusions with
+`reason: "not-applicable"`. Do not derive this artifact from leaf reports or
+change it merely because execution later runs out of budget. Pass its path as
+`-ExpectedCompositionPath` for final super-skill validation.
+Initialize `acceptedResults` to `[]`. After each leaf's acceptance gate, the
+host saves its exact accepted copy (or host-created failed validation result)
+in an immutable private file and appends its `id`, `version`, and `reportPath`
+to that array. The host alone owns these captures; neither workers nor the
+composing model may write them. Never derive them from composed `sub-results`.
+Keep the pre-dispatch selection and exclusions unchanged. Final validation
+requires every nested leaf to match its captured JSON content exactly and
+every captured result to be included. Property order is immaterial.
+
## Action
### Execution discipline (mandatory)
-The Action step is a sequence of **discrete iterations**, not one combined generation. The contract requires the super-skill to invoke each sub-skill in turn and then perform a self-review pass. Concretely this means:
+The Action step consists of **discrete leaf invocations**, not one combined generation. Invocation scheduling belongs to the orchestrator: independent leaves may run serially or concurrently, but their evaluation contexts and findings-reports remain isolated. Concretely this means:
-- **Isolate leaf invocations when the host supports it.** For fast/small models, each sub-skill SHOULD run in a fresh model call or child context containing only the task input, READ/DO contracts, the leaf instructions, a domain-filtered slice of the current knowledge index, and articles that leaf worklists. Preserve each index row's exact `path`; the leaf must copy references from that slice. The coordinator then collects the resulting JSON. This is the preferred fast-model profile: it bounds context, prevents later leaves from being skipped as attention is exhausted, and removes any reason to synthesize article paths.
-- Treat each sub-skill in the worklist as its own pass: read the sub-skill's instructions, apply its Source β Relevance β Worklist β Action steps to the orchestrator-supplied inputs, and produce that sub-skill's complete findings-report before moving on.
+- **Isolate leaf invocations when the host supports it.** Each sub-skill SHOULD run in a fresh model call or child context containing only its assigned source paths, READ/DO contracts, the leaf instructions, the complete bounded domain catalog per READ, and articles that leaf worklists. Preserve each catalog row's exact `path`; the leaf must copy references from that catalog.
+- **Keep run artifacts private.** Before dispatch, allocate a new GUID-named directory under the current session's artifact directory and a distinct scratch/report child directory for every leaf. Pass a leaf only its own assigned source paths and child directory, never the run root or sibling paths. A leaf MUST NOT discover, enumerate, read, modify, or delete sibling artifacts. Do not reuse a prior run directory, and do not clean up any run artifact until every leaf has finished and consolidation is complete.
+- **Keep raw Task transport distinct from the accepted copy.** Capture the exact Task return as the immutable raw audit payload and primary transport. Preserve it unchanged in the leaf's private artifacts or host log. Then apply DO's bounded pre-gate range normalization, when eligible, and its full consumer acceptance gate. The report accepted for rollup is the exact return when no normalization occurred, or the normalized candidate copy when DO permits it; worker-side persistence of another report file is optional and redundant.
+- **Treat automatic output spills as host-owned.** If the host reports that a Task return was automatically spilled, the coordinator MAY read that file read-only only at the exact path returned by the tool. Never modify, delete, enumerate around, or reuse an automatic spill path. Never bypass a content-exclusion or access denial.
+- Treat each sub-skill in the worklist as its own pass: read the sub-skill's instructions, apply its Source β Relevance β Worklist β Action steps to the orchestrator-supplied inputs, and produce that sub-skill's complete findings-report independently.
- Do not collapse multiple sub-skills into one shared reasoning step. Each sub-skill has a distinct knowledge subset and a distinct evaluation procedure; sharing one rolled-up scan dilutes per-skill attention and causes leaves to silently underreport (this has been observed in production: leaf skills returned empty `findings[]` while their standalone runs against the same diff produced multiple matches).
- 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.
+- Sub-skills are independent: re-walking the diff once per sub-skill is correct and expected. The output schema accommodates this β `sub-results` carries one entry per sub-skill, each a complete findings-report, in the frontmatter `sub-skills` order regardless of completion order.
+- When the execution budget prevents invoking every selected leaf, wait for started invocations to finish and preserve their accepted `sub-results`. Return `partial` if any returned report is non-`failed`, otherwise `failed`, and name the unfinished leaf IDs in `outcome-reason`. Do not run the self-review on incomplete composition, invent sub-results, or record budget exhaustion as a configured/input-incompatible skip. Never silently mark the remaining leaves clean.
### Roll up sub-skill findings
-For each sub-skill in the worklist, executed one at a time per the discipline above:
+For each sub-skill in the worklist:
1. Invoke the sub-skill with the orchestrator's inputs, passing only the subset each sub-skill declares in its `inputs`.
-2. Capture the sub-skill's complete findings-report verbatim and append it to `sub-results`.
-3. If the sub-skill's `outcome` is `failed`, stop here for this sub-skill: its findings are not reliable per the DO contract and MUST NOT be copied into the super-skill's top-level `findings[]` or counted in `summary.counts`.
+2. Capture the exact Task return as the immutable raw audit payload and primary transport. Preserve it unchanged in the leaf's private artifacts or host log before deriving a candidate. Apply only DO's bounded pre-gate normalization: when the complete raw report has no other defect, a finding has positive-integer `line`, `start-line`, and `end-line`, `start-line <= line <= end-line`, `start-line != line`, and no `suggested-code` field, copy the complete report and remove only that finding's optional `location.range`. Record the normalization separately in private run telemetry or artifacts, never in the findings-report. Validate the entire candidate through DO's existing strict acceptance gate. Accept the exact return when unchanged or the normalized candidate when it passes; otherwise record a separate failed validation result with no findings for rollup. Do not reconstruct JSON, infer fields, alter paths or references, clamp lines, normalize reversed or out-of-bounds ranges, remove a range associated with `suggested-code`, or salvage individual findings.
+3. Append the accepted findings-report, or the separate failed validation result, to `sub-results`. If its `outcome` is `failed`, stop here for this sub-skill: its findings are not reliable per the DO contract and MUST NOT be copied into the super-skill's top-level `findings[]` or counted in `summary.counts`.
4. Otherwise, compare each entry from the sub-skill's `findings[]` with findings already rolled up. Two findings are duplicates when they point to the same file and overlapping line/range and prescribe materially the same correction, even when their knowledge-file IDs differ. Merge duplicates instead of appending both: keep the more specific domain owner, preserve that finding's optional `domain` field verbatim (including its absence), use its reference as `references[0]` and therefore as `id`, append the other references as supporting references, keep the highest severity and confidence justified by either report, and preserve one self-contained message. Article and leaf ownership notes decide specificity; do not choose by execution order.
5. Append each non-duplicate finding, setting `from-sub-skill` to the sub-skill's `skill.id` and preserving its optional `domain` field verbatim, including its absence. For non-citation findings (those whose `id` is a skill-defined slug rather than a reference path), prefix `id` with `:` to prevent collisions across sub-skills. Other finding fields are preserved.
@@ -116,13 +147,21 @@ Sub-skills MAY also emit `suggested-code` when their knowledge file unambiguousl
### Summary and rollup
-Aggregate `summary.counts` and `summary.coverage` as the sums across invoked sub-skills whose `outcome` is not `failed`. Agent findings emitted by the super-skill itself contribute to `summary.counts` but not to `summary.coverage` (coverage is a sub-skill worklist metric and is undefined for self-review).
+Calculate `summary.counts` from the final top-level `findings[]`, after failed sub-results have been excluded and duplicates have been merged. Aggregate `summary.coverage` as the sums across invoked sub-skills whose `outcome` is not `failed`. Agent findings emitted by the super-skill itself contribute to `summary.counts` but not to `summary.coverage` (coverage is a sub-skill worklist metric and is undefined for self-review).
`suppressed[]` at the super-skill level remains empty. Knowledge-file-level suppression is reported by each sub-skill within its own entry in `sub-results`.
Derive `outcome` using the DO rollup rules. `outcome-reason` is populated for `partial` and `failed` and SHOULD summarize per-sub-skill state, for example: *"al-security-review failed (tool timeout); al-performance-review completed."*
-Before emitting the rollup, apply DO's reference-integrity gate to every nested and top-level finding. Every knowledge-backed ID/reference path must exist in the live checkout, must have been opened by the producing leaf, and must be copied verbatim rather than synthesized. Treat a sub-result containing an unverifiable citation as failed and exclude its findings from the top-level rollup.
+Before emitting the rollup, apply DO's consumer acceptance gate to every nested
+and top-level finding, and validate the final report with
+`-SkillKind super -ExpectedCompositionPath ` against the
+selection preserved before dispatch. A leaf's nested report is its accepted exact return or
+its accepted normalized candidate copy; its exact Task return remains the
+separate immutable raw audit payload. Treat an invalid sub-result as failed and
+exclude all of its findings from the top-level rollup. Never reconstruct it
+into a success-shaped report or perform normalization beyond DO's bounded
+exception.
## Output
@@ -300,7 +339,9 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip
}
```
-The empty-corpus case β BCQuality's state until knowledge files land β rolls up to `no-knowledge`:
+When the selected leaves find no applicable knowledge, the result rolls up to
+`no-knowledge`. This example shows two leaf results; a full run includes every
+invoked leaf:
```json
{
diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md
index 2386613..789db91 100644
--- a/microsoft/skills/review/al-data-modeling-review.md
+++ b/microsoft/skills/review/al-data-modeling-review.md
@@ -4,7 +4,7 @@ id: al-data-modeling-review
version: 1
title: AL data-modeling review
description: Performs an AL data-modeling review against guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `data-modeling` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Data-modeling findings are narrow by design β they apply when the diff touches setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, or audit fields. The skill returns `not-applicable` when none of those apply.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design β they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, audit fields, document print/email/Post-and-Send actions, `Navigate` page subscribers, Report Selection registration or dispatch, price-calculation/price-source extensibility, code that reads or writes sales/purchase/service line price and amount fields, `TransferFields`-based posting-cascade field mirroring, barcode/report-layout font-provider usage, dimension wiring, journal-based posting-routine structure, or Item Ledger Entry document-number lookups after a combined sales post. 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/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain data-modeling`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -37,21 +37,41 @@ 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 `* 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`).
+- The changed AL object names and types β especially `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, document or journal lines that reference a master, document pages/codeunits exposing print/email/Post-and-Send actions, codeunits subscribing to `Navigate`, enumextensions to `"Report Selection Usage"`/`"Price Calculation Handler"`/`"Price Source Type"`, and report objects that render barcodes.
+- The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, posting validation, and posting-cascade `TransferFields` calls.
+- 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`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`, `TransferFields`, `Navigate`, `OnAfterFindRecords`, `OnBeforeShowRecords`, `Report Selections`, `Report Selection Usage`, `InsertRecord`, `Document Sending Profile`, `PrintForCust`, `PrintWithDialogForCust`, `PrintWithDialogForVend`, `SendEmailToCust`, `SendEmailToVendor`, `Report.RunModal`, `Report.Run`, `Price Calculation Handler`, `Price Calculation`, `OnFindSupportedSetup`, `Price Calculation Setup`, `Price Source Type`, `PriceSourceList`, `OnAfterAddSources`, `UpdateUnitPrice`, `PlanPriceCalcByField`, `UpdateUnitPriceByField`, `Prices Including VAT`, `Unit Price`, `Direct Unit Cost`, `Line Amount`, `Prepmt. Line Amount`, `Amount Including VAT`, `CalculateOutstandingAmountExclTax`, `Barcode Font Provider`, `Barcode Font Provider 2D`, `EncodeFont`, `ValidateInput`).
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 new field is typed `Media`, `MediaSet`, or `BLOB` and the field's caption/name suggests a picture or image β `pictures-must-use-media-not-blob`.
+- Code outside a test codeunit or a demo-data generator calls `WorkDate(NewDate)` (the assignment form, not a bare `WorkDate()` read) as part of logic whose purpose is unrelated to the work date itself β `code-must-not-change-workdate`. A test deliberately setting a date context, or a demo-data routine that saves, sets, and restores the work date to backdate the data it creates, is not this anti-pattern.
+- A new or extended table's name, fields, or usage positively establish it as one of Business Central's nine business-record types β a name ending `Ledger Entry`/`Register`/`Journal Line`/`Header`/`Line`/`Setup`, an auto-generated `Entry No.`/`No.` key posted from elsewhere, a `Template Name`+`Batch Name`+`Line No.` key, or a singleton `Primary Key` field β `table-design-must-match-bc-table-type-conventions`. Do not worklist it from a bare `keys` block or primary-key declaration alone: a temporary/buffer table, a work queue, a log, a cross-reference/mapping table, or a process-local staging table is not one of the nine types and is out of this rule's scope entirely, not an unresolved case.
+- Code reads `Item Ledger Entry."Document No."` (or `"Last Shipping No."`/`"Last Posting No."`) after a combined Ship+Invoice **sales** post β `item-ledger-entry-document-no-follows-last-shipping-no`. This is a sales-specific rule: purchase combined posting is Receive+Invoice and uses receiving fields such as `"Last Receiving No."`, not the shipment/document-number behavior this article describes. Do not worklist it from purchase posting code.
- 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`.
+- A custom document header assigns defaults outside an `InitRecord` boundary, calls `InitRecord` before assigning its number, or places UI-independent defaults only in a page trigger β `initialize-document-defaults-in-initrecord`.
+- Directed `Round` calls use `'<'` as mathematical floor or `'>'` as mathematical ceiling, especially where negative amounts are possible β `round-direction-symbols-use-magnitude`.
+- A codeunit dispatches a document by calling `Report.Run`/`Report.RunModal` with a hardcoded report ID, or by building its own email directly, instead of going through the `Report Selections` usage for that document β `custom-document-dispatch-must-not-bypass-report-selections`. Either bypass is a finding on its own; both need not be present. Scope this to customer/vendor-facing documents that have (or should have) a `Report Selection Usage` β a hardcoded `Report.Run` of an ordinary list/analysis report is not this anti-pattern. A call that already goes through `Report Selections`' own Print/Email procedures is not this anti-pattern.
+- A document's own interactive Print/Email action routes through `Document Sending Profile` (`DocumentSendingProfile.Send`/`SendVendor`) instead of calling `Report Selections` (`PrintForCust`/`PrintWithDialogForCust`/`SendEmailToCust`/`PrintWithDialogForVend`/`SendEmailToVendor`) directly β `document-print-and-email-actions-call-report-selections-directly`. Do not flag `Document Sending Profile` usage that is genuinely part of a combined Post-and-Send action.
+- An `EventSubscriber` is added for `Navigate::OnAfterFindRecords` (registering a custom table in Find Entries) without a matching `Navigate::OnBeforeShowRecords` subscriber for the same table, or vice versa β `extend-find-entries-navigate-for-new-document-types`. Both subscribers must be added together for the same table.
+- An `enumextension` extends `"Report Selection Usage"` and registers a report via `ReportSelections.InsertRecord`, without subscribing to the matching *single* counterparty's full triad β the filter event (`OnAfterFilterCustomerUsageReportSelections` on `page 9657` for a sales usage, `OnAfterFilterVendorUsageReportSelections` on `page 9658` for a purchase usage) AND the page-facing usage-enum map/validate events (`enumextension` on `"Custom Report Selection Sales"`/`"Report Selection Usage Vendor"` plus the matching map/validate subscribers) β `extend-report-selection-usage-for-new-document-types`. Requiring or wiring *both* counterparties by default for a one-sided document is also the anti-pattern (`ReportSelectionHandlerCZZ` partitions strictly by counterparty); only a genuinely two-sided usage (as `ReportSelectionHandlerCZC` demonstrates for Compensation) needs both.
+- The same field number is added as a new field on two or more tables connected by a `TransferFields` call in a posting cascade (e.g. a header table and the posted-document table `SalesPost.Codeunit.al`/`PurchPost.Codeunit.al` transfer into), with a different data type or length on one side β `transferfields-mirrored-fields-must-match-type-and-length`. A field defined on only one side of the cascade is out of scope; this cues only on a field deliberately mirrored across the cascade with a type or length mismatch.
+- An `enumextension` extends `"Price Calculation Handler"` and implements the `Price Calculation` interface, without a matching `OnFindSupportedSetup` subscriber inserting a `Price Calculation Setup` record naming that implementation as the `Implementation` for a `Method`/`Type`/`Asset Type` β `activate-new-price-calculation-handler-via-onfindsupportedsetup`. `Default := true` is only required on that row when it is meant as the fallback for its `Method`/`Type`/`Asset Type` combination; a row meant to be selected only through an explicit, specific `"Dtld. Price Calculation Setup"` row does not need it, so do not flag a missing `Default := true` by itself β flag the missing setup row/subscriber entirely.
+- An `enumextension` extends `"Price Source Type"` with a new value intended for a sales, purchase, or job price list, without extending the matching document subset enum (`"Sales Price Source Type"`, `"Purchase Price Source Type"`, `"Job Price Source Type"`) with a value at the same numeric ID β `extend-price-source-type-must-sync-document-subset-enum`.
+- A codeunit subscribes to `"Sales Line - Price"`'s `OnAfterAddSources` to register a custom field as a price source via `PriceSourceList.Add`, but that field has no `OnValidate` (or matching `OnAfterValidate`) that triggers recalculation β either `SalesLine.UpdateUnitPrice()`, or the explicit `SalesLine.PlanPriceCalcByField()` followed by `SalesLine.UpdateUnitPriceByField()`. A bare `UpdateUnitPriceByField` without a preceding `PlanPriceCalcByField` for the same field number does not count as recalculation (it exits without recalculating) β `new-price-source-must-add-candidate-and-trigger-recalculation`.
+- Code reads `Unit Price`, `Direct Unit Cost`, `Line Amount`, `Line Discount Amount`, `Inv. Discount Amount`, `Prepmt. Line Amount`, `Prepmt. Amt. Inv.`, `Prepmt Amt to Deduct`, `Prepmt Amt Deducted`, or the result of `CalculateOutstandingAmountExclTax` of a `Sales Line`/`Purchase Line`/`Service Line` as a known net or gross value (a net/gross total, a VAT computation, a comparison with `Amount` or `Item."Unit Price"`/`"Last Direct Cost"`, an export), or writes a source price of known basis into `Unit Price`/`Direct Unit Cost`, without reading the document header's `Prices Including VAT` β `document-line-prices-follow-prices-including-vat`. Reads of fixed-basis fields (`Amount`, `Amount Including VAT`, `Prepayment Amount`, `Prepmt. Amt. Incl. VAT`), combinations of header-dependent fields with each other, prices returned by the standard price calculation, and copies between lines of the same document are not this anti-pattern.
+- A report hand-constructs a barcode string only where a concrete, independently provable defect is visible: the source value can contain characters outside the symbology's character set and is never validated, a checksum the symbology/setup requires is never applied, or there is concrete evidence of an incompatible font binding. Do not flag manual start/stop delimiters by themselves β `*value*` is a documented, valid Code 39 form for IDAutomation fonts (IDAutomation also accepts parentheses), so delimiter choice alone is never a finding. Also flag module use that does not match the interface: a 1D `"Barcode Font Provider"` path must call both `ValidateInput` and `EncodeFont`; a 2D `"Barcode Font Provider 2D"` path calls `EncodeFont` only (the 2D interface has no `ValidateInput`, so its absence there is not a finding). Separately, flag an otherwise correctly encoded barcode whose report layout names an evaluation/demo font instead of the purchased production font name β `report-barcodes-must-use-barcode-module-and-production-font-name`.
+- A new field is typed `Code`/`Text` and its `OnValidate` calls `DimensionManagement`/`DimMgt`, or a table adds Shortcut Dimension fields, a `Dimension Set ID` field, or `AddDimSource`/`GetDefaultDimID` β `dimension-management-wiring`. A master table calling `SaveDefaultDim` and a document/journal table computing its own `Dimension Set ID` are two different valid shapes; do not flag a master table for lacking a `Dimension Set ID` field or a document for lacking `SaveDefaultDim`.
+- A journal-based posting codeunit is added or changed and validation, Journal-table access, ledger writes, and user-interaction (`Confirm`/dialogs) all occur in one procedure or one codeunit, rather than split across `Check Line`/`Post Line`/`Post Batch`-shaped companions β `check-post-line-batch-pattern`. A document posting routine calling `Post Line` directly without a `Post Batch` companion is not this anti-pattern.
+- An existing, already-published table's `keys` block adds, removes, or reorders a field in its primary key or any `Clustered = true` key β `do-not-change-primary-key`. A new table defining its own key for the first time is not this anti-pattern; requires repository/publication context to know the table has already shipped.
+- Code reads a setup/configuration-table field inside a branch that has already decided the value is required, and blank/zero is handled with a fallback to a default rather than `TestField`/an equivalent guard β `testfield-required-setup-field`. A read that is genuinely optional in that branch, or one already guarded by `TestField`, is not this anti-pattern.
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`.
@@ -81,7 +101,7 @@ 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.
+- `not-applicable` β the diff touches no setup/master table, page, key, numbering, block-check, or audit-field surface, and no document print/email/Post-and-Send action, `Navigate` subscriber, Report Selection registration/dispatch, price-calculation/price-source extensibility point, sales/purchase/service line price or amount read/write, posting-cascade `TransferFields` mirroring, barcode/report-font-provider usage, dimension wiring, posting-routine structure, or Item-Ledger-Entry-document-number surface.
- `partial` β a budget was hit before the worklist was exhausted.
- `failed` β an unrecoverable error occurred.
diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md
index 39851ac..3962363 100644
--- a/microsoft/skills/review/al-error-handling-review.md
+++ b/microsoft/skills/review/al-error-handling-review.md
@@ -4,7 +4,7 @@ id: al-error-handling-review
version: 1
title: AL error handling review
description: Reviews AL source changes against error-handling guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,18 @@ application-area: [all]
Reviews AL source changes against the `error-handling` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `error-handling` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/error-handling/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain error-handling`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
+
+When the review scope contains outbound `HttpClient.Get` or `HttpClient.Post` calls, including resolved call paths, also retrieve every catalog page with `-Domain web-services`, using the same known task dimensions and enabled layers. Restrict supplementary candidates to these article slugs across enabled layers; the links identify their canonical owners:
+
+- [`handle-httpclient-platform-failure-before-response-access`](../../knowledge/web-services/handle-httpclient-platform-failure-before-response-access.md)
+- [`check-http-status-before-consuming-response-body`](../../knowledge/web-services/check-http-status-before-consuming-response-body.md)
+
+Retain each selected catalog row's exact `path`; do not invent paths for missing, pruned, or disabled entries. Apply this leaf's Relevance, Worklist, and READ layer precedence to the supplementary candidates before retrieving complete bodies with `Get-KnowledgeArticles.ps1`. Apply each selected article's own scope and exceptions when evaluating code and agent-finding candidates. Use READ's bounded path-discovery fallback over these same sources when needed. This supplements error-handling knowledge, not the scope of the review with unrelated web-services concerns.
## Relevance
@@ -40,6 +47,7 @@ Narrow the relevant files to the subset that applies to the changes under review
- The changed AL object names and types β especially codeunits that post or validate, tables and table extensions with `OnValidate` triggers, and any procedure that raises errors or orchestrates a batch over records.
- The changed procedures and triggers, weighted toward `OnValidate`/`OnInsert`/`OnModify` triggers, posting and validation routines, and procedures attributed with `[ErrorBehavior(...)]` or `[TryFunction]`.
- Tokens extracted from the diff that relate to error surfacing and diagnostics (`Error`, `ErrorInfo`, `FieldError`, `TestField`, `Title`, `Message`, `DetailedMessage`, `AddAction`, `AddNavigationAction`, `RecordId`, `PageNo`, `ErrorBehavior`, `Collect`, `HasCollectedErrors`, `GetCollectedErrors`, `ClearCollectedErrors`, `ErrorType`, `Internal`, `Client`, `TryFunction`, `GetLastErrorText`, Boolean assignment).
+- For the outbound HTTP call paths identified in Source, include `HttpClient`, `Get`, `Post`, `HttpResponseMessage`, response use, and caller failure handling (including `[TryFunction]` call sites) in keyword and topic matching.
- 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.
@@ -47,6 +55,8 @@ A file enters the candidate worklist when its `keywords` intersect the extracted
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`.
+- New or changed code inserts an error/duration log record around a failed `TryFunction`/`GetLastErrorText`/`GetLastErrorCode` path and then raises, propagates, or rethrows the error β `log-writes-must-survive-rollback`. Do not worklist it when the log insert already happens inside a `Session.StartSession`-targeted codeunit's `OnRun`; that is the compliant shape, not the signal to flag.
+- A guarded lookup (`if Record.Get(...) then ... else` or similar) sets a value used later, and the same guard shape (with the same blank/zero fallback style) is applied to a field that feeds a posted amount, a tax/VAT calculation, a quantity or price actually used in a transaction, or a legally/compliance-facing output β `defensive-vs-offensive-code-must-match-blast-radius`. The signal is a posting-critical or compliance-facing field guarded defensively with a silent fallback, not the mere presence of a guarded lookup.
- 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`.
@@ -132,7 +142,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
}
```
-The empty-corpus case β BCQuality's state until error-handling knowledge files land β produces:
+When no applicable error-handling knowledge is available, the report is:
```json
{
diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md
index c854f1c..b257d3d 100644
--- a/microsoft/skills/review/al-events-review.md
+++ b/microsoft/skills/review/al-events-review.md
@@ -4,7 +4,7 @@ id: al-events-review
version: 1
title: AL events review
description: Reviews AL source changes against events-and-subscribers guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `events` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `events` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/events/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain events`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review
- The changed AL object names and types β especially codeunits that publish events or host event subscribers, posting/release/validation routines that should expose extension points, and test codeunits that bind subscribers.
- The changed procedures and triggers, weighted toward event publisher methods, methods carrying the `[EventSubscriber(...)]` attribute, routines that raise `OnBefore`/`OnAfter` events, and any procedure that calls `BindSubscription`/`UnbindSubscription`.
-- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `InternalEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `GlobalVarAccess`, `Isolated`, `local`, `internal`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`).
+- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `InternalEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `GlobalVarAccess`, `Isolated`, `local`, `internal`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`, `ChangeCompany`, `StartSession`, `RunTrigger`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone.
@@ -65,6 +65,7 @@ The following targeted checks map diff signals to specific `events` articles. Tr
- A `RecordRef` event parameter, or a passed-through `xRec`, where a concrete typed record fits β `avoid-loosely-typed-event-parameters`.
- A `var IsHandled` added to a pre-existing event rather than introduced through a new `OnBefore` publisher β `do-not-add-ishandled-to-an-existing-event`.
- An `if IsHandled then exit;` whose skipped body performs posting, ledger-entry creation, number-series consumption, or integrity/permission validation β `do-not-bypass-critical-operations-with-ishandled`.
+- A record variable that had `ChangeCompany()` called on it and is later used with `Insert`, `Modify`, `Delete`, or `Validate`, where the table is not owned by the extension, has triggers that read company data, or has trigger-event subscribers that do not exit on `RunTrigger = false` β `changecompany-runs-triggers-in-the-calling-company`. Do not match a read-only use after `ChangeCompany`, a write with `RunTrigger = false` into an extension-owned table whose triggers do not read company data and whose trigger-event subscribers exit on `RunTrigger = false`, or the parameterless `ChangeCompany()` reset.
## Action
@@ -141,7 +142,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
}
```
-The empty-corpus case β BCQuality's state until events knowledge files land β produces:
+When no applicable events knowledge is available, the report is:
```json
{
diff --git a/microsoft/skills/review/al-finance-review.md b/microsoft/skills/review/al-finance-review.md
new file mode 100644
index 0000000..06090b1
--- /dev/null
+++ b/microsoft/skills/review/al-finance-review.md
@@ -0,0 +1,146 @@
+---
+kind: action-skill
+id: al-finance-review
+version: 1
+title: AL Finance review
+description: Reviews financial journal posting, ledger corrections, applications, VAT handling, and posting-linked dimensions against BCQuality Finance guidance.
+inputs: [pr-diff, file-path, folder-path]
+outputs: [findings-report]
+bc-version: [all]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# AL Finance review
+
+Reviews the `finance` knowledge domain. This is a leaf action skill composed by
+`al-code-review`; it invokes no other skills. Application-area metadata does
+not gate Finance coverage; resolved source records and operations do.
+
+## Source
+
+Apply the source-surface gate in Relevance before retrieving knowledge.
+If it passes, use READ's **Bounded retrieval for review skills** workflow with
+`-Domain finance`. Consume every catalog page across enabled layers, preserving
+each exact path and applicability metadata. Do not select a top-k catalog or
+deduplicate by basename. Open complete bodies only for exact Worklist paths,
+in stable chunks of at most eight, consuming every continuation. If the helper
+or prepared index is unavailable or invalid, use READ's explicit path-discovery
+and bounded native-read fallback; a retrieval error is not an empty corpus.
+
+## Relevance
+
+Apply READ's frontmatter matching rules using only known task dimensions.
+Use the target application version from `app.json` when available; do not invent
+a country or application area from a filename or UI `ApplicationArea` token.
+Retain conditional articles only when configured, cap resulting confidence at
+`medium`, and name every unknown dimension in the finding.
+
+Inspect the supplied AL scope and its enclosing declarations. Admit only
+changed executable behavior involving at least one of these surfaces:
+
+- General-journal construction or posting, journal-batch processing, or an
+ event subscriber whose resolved publisher is in the financial posting path.
+- Writes or correction/application/reversal calls involving `G/L Entry`,
+ `Cust. Ledger Entry`, `Vendor Ledger Entry`, `Detailed Cust. Ledg. Entry`,
+ `Detailed Vendor Ledg. Entry`, `VAT Entry`, or financial-posting/G/L-register
+ records.
+- Dimension transfer or dimension-set mutation connected by visible data flow
+ to an existing general journal, financial posting document, or Finance-owned
+ ledger record.
+
+Exclude `Item Ledger Entry`, `Value Entry`, Capacity/Warehouse entries,
+`Item Application Entry`, and other inventory-posting records owned by SCM.
+Do not adopt their findings when the SCM skill is absent or disabled. For one
+inventory-originated posting bypass, equivalent findings have one SCM primary
+owner; distinct independent financial defects remain Finance. Classify the
+operation and actual record, not an inventory/finance word in a module name.
+
+Return `not-applicable` when none is present. Imports, object names, comments,
+read-only ledger displays, generic `Amount`/`Date`/`Open` fields, and calls to
+`DimensionManagement` without posting-linked context do not establish relevance.
+For a diff, retain surrounding variable types, field provenance, event
+attributes, and reachable helpers; do not review isolated added lines without
+the context needed to classify their record or call.
+
+## Worklist
+
+Match the complete relevant catalog's keywords, titles, and descriptions to
+the admitted source surfaces. Add an exact catalog path only when its concern
+maps to the changed behavior; generic financial vocabulary is not enough.
+The following deterministic cues must select their named articles even if
+keyword ranking would otherwise omit them:
+
+- Persistent Finance-owned ledger inserts reached from extension posting
+ code β `post-ledger-entries-through-posting-codeunits`.
+- Persisted general-journal lines posted through a line-codeunit loop or custom
+ aggregate check, with visible template/document/date balancing context β
+ `preserve-journal-batch-document-balance`.
+- Imported net/tax/gross values mapped into `Gen. Journal Line.Amount`, with
+ evidence of the VAT posting mode and posting-setup combination β
+ `normal-vat-journal-amount-includes-vat`.
+- Persisted original Finance accounting-value changes, deletion of Finance rows, or
+ fabricated reversal flags/links β `do-not-modify-or-delete-posted-ledger-entries`.
+- Customer/vendor settlement/reopening code writing `Open`, closure fields,
+ detailed customer/vendor application amounts, or unapplication flags β
+ `apply-ledger-entries-through-application-codeunits`.
+- A due-date change persisted on an existing customer/vendor ledger entry β
+ `change-ledger-due-dates-through-entry-edit`.
+- A `Reversal Entry.ReverseTransaction` or `ReverseRegister` argument with
+ visible ledger-entry, transaction, or register provenance β
+ `reverse-transactions-by-transaction-number`.
+- A complete Finance posting-dimension transfer represented by shortcut/global
+ fields or a `Dimension Set ID` assignment β
+ `write-dimensions-as-dimension-set-entries`.
+- A dimension/value membership change on `Dimension Set Entry`, reached from
+ a general-journal/financial-document/Finance-ledger set ID β
+ `do-not-edit-shared-dimension-sets`.
+
+These are retrieval cues, not findings. Use the selected articles' normative
+exceptions and ownership boundaries to classify standard workflows, temporary
+records, operational edits, and extension fields. Do not select a Finance
+ledger rule from `*Ledger Entry` or `Insert`/`Modify` alone. Finance does not
+own SCM records, generic custom-table/master dimension wiring, number-series
+API migration, or general AL validation, locking, transaction, and event-style
+advice. Do not add those concerns as Finance agent findings.
+
+Resolve actual normative conflicts across layers per READ and record suppressed
+candidates per DO. Keep every remaining exact path in a stable worklist.
+Return `no-knowledge` if no applicable Finance knowledge survives filtering or
+configuration; return `completed` with no findings when applicable knowledge
+exists but no article matches the admitted changes.
+
+## Action
+
+Evaluate every worklist article in full against the changed behavior and its
+surrounding control flow. Establish record type, existing versus newly prepared
+state, temporariness, fields actually persisted, argument provenance, and the
+posting/edit API boundary before emitting a finding. Do not infer a financial
+defect from a method name, missing external setup, or unsupported speculation
+about callers.
+
+Use the most specific article for the correction: application-state, due-date,
+and shared-dimension findings must not also become generic posted-row findings
+for the same change. Do not emit an equivalent Finance finding for the
+financial-row leg of one SCM-owned inventory posting bypass; evaluate a
+distinct financial defect only when its corrective action is independent.
+Emit `major` for a demonstrated financial-correctness
+violation and reserve `blocker` for directly evidenced destructive corruption
+under DO's severity rules. Applicability alone produces no finding.
+
+Set `high` confidence only for established source evidence and known matching
+context. Domain-scoped agent findings follow DO's precision bar and remain
+capped at `minor`/`medium`; do not broaden this pass into other AL domains.
+Provide literal `suggested-code` for complete, local, unambiguous fixes.
+Otherwise give `suggested-code-omission-reason`, particularly when selecting
+the correct posting workflow requires business context.
+
+Follow DO's acceptance gate and outcome rules. Report `partial` rather than
+silently dropping worklist items when a budget is reached, and `failed` for an
+unrecoverable retrieval or evaluation error.
+
+## Output
+
+Output conforms to the DO findings-report contract. Every finding this skill
+emits MUST set `findings[].domain` to `"Finance"`.
diff --git a/microsoft/skills/review/al-interfaces-review.md b/microsoft/skills/review/al-interfaces-review.md
index 0031e8f..1c38be7 100644
--- a/microsoft/skills/review/al-interfaces-review.md
+++ b/microsoft/skills/review/al-interfaces-review.md
@@ -4,7 +4,7 @@ id: al-interfaces-review
version: 1
title: AL interfaces review
description: Reviews AL source changes against interface and enum-with-implementation guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `interfaces` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `interfaces` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/interfaces/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain interfaces`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -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`, `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).
+- Tokens extracted from the diff that relate to interfaces and enum-backed implementation (`interface`, `extends`, `implements`, `Implementation`, `DefaultImplementation`, `UnknownValueImplementation`, `enum`, `Extensible`, `Interface`, `Variant`, `is`, `as`, `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.
@@ -54,6 +54,7 @@ The following targeted checks map diff signals to specific `interfaces` articles
- `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`.
+- An `Interface` or `Variant` is cast with `as` to an optional extended interface without first establishing support with `is` β `guard-interface-casts-with-is`.
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.
diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md
index bf2f4e8..6eae659 100644
--- a/microsoft/skills/review/al-performance-review.md
+++ b/microsoft/skills/review/al-performance-review.md
@@ -4,7 +4,7 @@ id: al-performance-review
version: 1
title: AL performance review
description: Reviews AL source changes against performance guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `performance` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `performance` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/performance/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain performance`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -39,19 +39,30 @@ Narrow the relevant files to the subset that applies to the changes under review
- The changed AL object names and types β especially tables, pages with SourceTable bindings, reports, queries, and codeunits performing record iteration.
- The changed procedures and triggers, weighted toward those that perform loops, Find/FindSet/FindFirst calls, CalcFields, SetAutoCalcFields, CalcSums, FlowField access, Commit calls, checkpoint helpers, record copying, RecordRef conversion, Modify/Delete calls, or cross-table navigation.
-- Tokens extracted from the diff that relate to data access and hot-path costs (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`, `FlowField`, `Visible`).
+- Tokens extracted from the diff that relate to data access, hot-path costs, and background scheduling (`key`, `IncludedFields`, `SumIndexFields`, `SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `FindLast`, `IsEmpty`, `ReadIsolation`, `LockTable`, `Insert`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Validate`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`, `CalcFormula`, `FlowField`, `Query.Open`, `Query.Read`, `Visible`, `Job Queue Entry`, `Job Queue Category Code`, `Confirm`, `RunModal`, `GuiAllowed`, `TryFunction`, `Codeunit.Run`, `HttpClient`, `Status`, `On Hold`, `stop request`, `TaskScheduler.CreateTask`, `TaskScheduler.TaskExists`, `Page.RunModal`, `Report.RunModal`, `Report.Run`, `Xmlport.Run`, `UseRequestPage`).
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 `design-covering-keys-from-read-pattern.md` for a changed secondary key alongside a filtered reader of its table, and `review-overlapping-keys-before-adding-an-index.md` when added or changed keys have overlapping leading fields. A changed key alone is not a finding; read and write workloads determine whether either rule applies.
+- Worklist `preserve-buffered-inserts-by-separating-target-reads.md` when a loop calls `Insert` and interleaves operations on the insert target or `Commit`. Worklist `aggregate-before-persisting-intermediate-results.md` when repeated grouping calculations and persistent intermediate summaries appear in the same processing path. Do not infer either pattern from `Insert` or `CalcSums` alone.
+- Worklist `cache-repeated-filtered-results-with-explicit-scope.md` only when the code or workload establishes repeated **complete** lookup keys (for example, querying the same filtered set in multiple passes, or measured key reuse), or shows a cache that omits result-affecting inputs. A single loop over possibly distinct keys does not establish reuse or justify a cache finding. Worklist `avoid-repeating-unchanged-validation.md` for repeated `Validate` of the same field in one path; do not worklist it from a single validation call.
+- 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. Also worklist `calcsums-instead-of-calcfields-in-loop.md` when the loop accumulates one set total: use `CalcSums` directly only for stored source fields, never directly on FlowFields; a FlowField total requires deriving equivalent source filters from its `CalcFormula`.
- 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` when `Commit()` is inside a record-iteration body or a checkpoint loop lacks persisted progress that excludes completed work on retry. Do not match a commit after a complete business unit when the same transaction persists a restart-safe watermark/state and errors propagate. Still match a full-tail `FindSet` with periodic commits as unbounded retrieval; restart safety does not make it `TOP X`.
- Worklist `prefer-modifyall-over-per-row-modify.md` for a constant-assignment `Modify(false)` loop with no validation or per-row semantics. Worklist `triggers-and-media-field-regress-modifyall.md` when table trigger code, related subscribers, security filtering, `Media`/`MediaSet`, or companion fields affect a bulk path. A progress dialog does not generically exempt a loop; accept it only when the equivalent bulk call already falls back to individual operations and semantics are preserved.
- Worklist `avoid-cloning-records-before-modify-delete-in-loops.md` when an iteration calls `Copy` or `RecordRef.GetTable` before `Modify`/`Delete`, or passes the iterated record without `var` to a helper that writes that record. Do not worklist it from `Modify`, `Delete`, or `RecordRef` alone; exclude a direct write on the iterator, a read-only copy, a temporary record, a different target table, and a `RecordRef` opened and iterated directly.
- Worklist `use-tryfunction-for-error-catching-not-rollback.md` only when writes occur inside a try method and the code or surrounding flow expects an error to roll them back. A bare try-method call whose Boolean result is ignored belongs exclusively to `error-handling/ignored-tryfunction-return-disables-try-semantics.md`; do not worklist the performance article from that call shape alone.
- For `LockTable` in a pure read helper, select exactly one owner. Use `do-not-locktable-in-read-only-procedure.md` when the helper needs no stronger isolation and should remove the lock. Use `prefer-readisolation-over-locktable-for-reads.md` instead when the code explicitly requires committed-read semantics and `ReadIsolation` is the replacement. Never emit both findings for the same call.
+- Worklist `job-queue-handlers-must-not-require-ui.md` when a codeunit run by the job queue calls `Confirm`, `Page.Run`, `Page.RunModal`, `Report.Run`, `Report.RunModal`, `Hyperlink`, `File.Upload`, or `File.Download`, or uses `Message` as its only success or failure notification. Exclude optional UI-only behavior guarded by `GuiAllowed`; do not exclude a guard that silently skips a decision required by the operation.
+- Worklist `job-queue-handlers-must-propagate-failures.md` when a codeunit run by the job queue handles a failed `TryFunction`, `Codeunit.Run`, or another Boolean-returning operation with `exit` or normal fall-through, causing the dispatcher to observe success. Exclude intentional partial-success handling that persists or emits an observable aggregate outcome. A bare try-method call whose Boolean result is ignored remains owned exclusively by `error-handling/ignored-tryfunction-return-disables-try-semantics.md`.
+- Worklist `job-queue-external-effects-must-be-idempotent.md` when rerunnable job queue work reads an outbox row, performs a state-changing external request, then updates or deletes local data without sending a stable request ID understood by the external system. Exclude naturally idempotent operations and requests whose body, URI, headers, or business key lets the external service return the existing result instead of repeating the side effect.
+- Worklist `job-queue-on-hold-does-not-stop-running-work.md` when a running job queue handler polls the entry's `Status` or `On Hold` value as a cancellation signal. Exclude application-owned stop requests that are checked before every bounded unit of work, including the first, when completed work and its checkpoint remain consistent and resume logic clears the request.
+- Worklist `job-queue-category-code-serializes-conflicting-jobs.md` when two or more job queue entries in the same company are shown by the changed context to require mutual exclusion but have empty or different Job Queue Category Codes. Do not infer a conflict merely because jobs touch the same tables, and do not recommend a category to coordinate across companies, environments, or workers outside the job queue dispatcher.
+- Worklist `store-scheduled-task-id-to-avoid-duplicate-tasks.md` when `TaskScheduler.CreateTask` runs from initialization, login, setup, or another repeatable path without persisting its returned GUID and checking it with `TaskScheduler.TaskExists` before creating a replacement. Exclude one-shot creation and correctly persisted check-before-create flows; concurrent callers still require serialization around that sequence.
+- Worklist `al-methods-limited-during-write-transactions.md` when `Page.RunModal` follows an `Insert`, `Modify`, or `Delete` in the same trigger or procedure with no intervening `Commit`; or when `Report.RunModal`/`Report.Run` without `false` as its `RequestWindow` argument and without `UseRequestPage(false)` follows one; or when `Xmlport.Run` without `false` as its `RequestWindow` argument and without the `UseRequestPage = false` object property follows one. Do not worklist it from a call that precedes every write, from a report run with its request page suppressed via `UseRequestPage(false)`/`Run(...,false)`/`RunModal(...,false)`, or from an XMLport run with its request page suppressed via the `RequestWindow` argument or the `UseRequestPage` property. A `Codeunit.Run` whose return value is used in that position belongs to `codeunit-run-requires-prior-commit-inside-transaction.md`.
+- A new or changed report object whose usage/name/caption identifies it as a single-record document (invoice, statement, order confirmation) sets or retains `DefaultRenderingLayout = RDLC` β `document-report-word-layout.md`. Do not worklist this from a tabular/list report with heavy aggregation or calculated columns; RDLC/Excel remains the better fit there.
These targeted inclusions and exclusions override generic token overlap. Do not retain an excluded article solely because the diff contains one of its keywords.
@@ -134,7 +145,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
}
```
-The empty-corpus case β BCQuality's state until performance knowledge files land β produces:
+When no applicable performance knowledge is available, the report is:
```json
{
diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md
index 0bbd8ae..17b4e7b 100644
--- a/microsoft/skills/review/al-privacy-review.md
+++ b/microsoft/skills/review/al-privacy-review.md
@@ -4,7 +4,7 @@ id: al-privacy-review
version: 1
title: AL privacy review
description: Reviews AL source changes against privacy and data-classification guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `privacy` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `privacy` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/privacy/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain privacy`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
diff --git a/microsoft/skills/review/al-query-review.md b/microsoft/skills/review/al-query-review.md
index c4ea895..e97a20a 100644
--- a/microsoft/skills/review/al-query-review.md
+++ b/microsoft/skills/review/al-query-review.md
@@ -4,7 +4,7 @@ id: al-query-review
version: 1
title: AL Query review
description: Reviews AL Query objects and Query instance usage against BCQuality guidance.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -18,7 +18,7 @@ Reviews AL source changes against the `query` knowledge domain in BCQuality. Thi
## Source
-Read `knowledge-index.json` once and take entries whose `domain` is `query` across enabled layers. Open an article body only after it enters the Worklist. If the index is unavailable, discover `*/knowledge/query/*.md` by path.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain query`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -32,22 +32,24 @@ Match relevant entries against changed `query` objects, variables typed as `Quer
The following targeted checks cover every current `query` article:
+- A `DataItemTableFilter` and a runtime `SetFilter` or `SetRange` constrain the same source field incompatibly, while the runtime call is intended to replace or broaden the static filter β `dataitemtablefilter-cannot-be-overwritten-at-runtime`.
- `SetFilter` or `SetRange` occurs after `Open()` without a new `Open()` before the next `Read()` β `set-query-filters-before-open`.
+- A runtime `SetFilter` or `SetRange` replaces a `ColumnFilter` on the same column or filter row, while later code relies on the declarative restriction remaining effective β `setfilter-overwrites-query-columnfilter`.
- An already-open query is opened again as if that advanced the cursor, or a query variable is reused for an independent operation without `Clear` even though old filters must not carry over β `reopening-query-resets-cursor-but-keeps-filters`.
Resolve layer conflicts per READ. When no query knowledge exists, emit `no-knowledge`; when knowledge exists but no article matches the changed Query usage, emit `completed` with no findings.
## Action
-Evaluate every worklist article against the diff's Query call order and surrounding control flow.
+Evaluate every worklist article against the Query definition, the diff's call order, and surrounding control flow. For filter-precedence findings, require both the declarative filter and the runtime call to be visible, and require local evidence that replacement, broadening, or retention of the original filter is intended.
-- Emit `major` for an unambiguous Anti Pattern that can close the dataset, restart processing, or retain an unintended filter.
+- Emit `major` for an unambiguous Anti Pattern that can close the dataset, restart processing, retain an unintended filter, produce an empty intersection, or admit rows excluded by an overwritten filter.
- Emit `minor` when code contradicts a Best Practice but the resulting behavior depends on unseen control flow.
- Do not emit applicability-only information. A Query article produces a finding only when the changed code violates its normative guidance.
Set confidence to `high` for a locally visible call sequence and `medium` when aliases, helper calls, or missing context obscure the sequence. Domain-scoped agent findings follow DO's precision bar and remain capped at `minor`/`medium`.
-Provide `suggested-code` only when moving a filter before `Open()` or adding `Clear` is a complete, local, unambiguous replacement. Otherwise set `suggested-code-omission-reason`.
+Provide `suggested-code` only when moving a filter before `Open()`, adding `Clear`, moving an invariant restriction to `DataItemTableFilter`, or composing the complete runtime filter is a complete, local, unambiguous replacement. Otherwise set `suggested-code-omission-reason`.
Outcome selection follows DO: `completed`, `no-knowledge`, `not-applicable`, `partial`, or `failed`.
diff --git a/microsoft/skills/review/al-reporting-review.md b/microsoft/skills/review/al-reporting-review.md
new file mode 100644
index 0000000..52f8f53
--- /dev/null
+++ b/microsoft/skills/review/al-reporting-review.md
@@ -0,0 +1,63 @@
+---
+kind: action-skill
+id: al-reporting-review
+version: 1
+title: AL reporting review
+description: Reviews AL Report and ReportExtension code against BCQuality reporting guidance.
+inputs: [pr-diff, file-path, folder-path]
+outputs: [findings-report]
+bc-version: [all]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# AL reporting review
+
+Reviews AL source changes against the `reporting` knowledge domain in BCQuality. This is a leaf action skill composed by `al-code-review`.
+
+## Source
+
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain reporting`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
+
+## Relevance
+
+Apply READ's frontmatter matching rules against the task context. Use the target version from `app.json` when available and `[al]` for technologies. Retain conditionally applicable files only when configured; cap resulting confidence at `medium` and name every unknown dimension in the finding message.
+
+Return `not-applicable` when the input contains no Report or ReportExtension declaration and no Report variable or method call.
+
+## Worklist
+
+Match relevant entries against changed `report` and `reportextension` objects, variables typed as `Report`, and the tokens `CurrReport`, `Skip`, `Break`, `Quit`, `Run`, `RunModal`, `RunRequestPage`, `Execute`, `Print`, `SaveAs`, `DownloadFromStream`, `Data Compression`, `SetTableView`, `DataItemTableView`, `OnPreReport`, `OnPostReport`, `OnPreDataItem`, `OnAfterGetRecord`, and report-extension dataset triggers.
+
+Apply this targeted check even when token overlap would rank the article below the worklist cutoff:
+
+- The same Report variable has two logically independent `RunModal()` executions without `Clear` before the second configuration β `clear-report-variable-before-independent-runmodal`.
+- `CurrReport.Break()` is used inside an explicit loop while reachable statements after the loop are expected to finish the current trigger β `currreport-break-ends-the-current-trigger`.
+- `CurrReport.Quit()` follows database writes or the report relies on `OnPostReport` finalization β `currreport-quit-rolls-back-and-skips-onpostreport`.
+- `CurrReport.Skip()` is followed by reachable code in the same trigger, or later record triggers contain work that is unsafe for skipped records β `currreport-skip-does-not-stop-trigger-code`.
+- A loop reachable from one Web client action calls `Report.Run`, `Report.RunModal`, or `DownloadFromStream` more than once instead of producing one archive download β `report-output-in-a-loop-needs-one-client-download`. Do not select this article when the context is non-Web or the loop is provably single-iteration.
+- A ReportExtension before-trigger establishes a filter or value that visible base-trigger code subsequently replaces β `reportextension-dataitem-trigger-order-is-explicit`. Do not select this article from a before-trigger alone.
+- A ReportExtension `OnPreReport` prepares state consumed by the base `OnPreReport`, or its `OnPostReport` prepares state already consumed by the base `OnPostReport` β `reportextension-report-triggers-run-after-base-triggers`. Require visible base behavior or equivalent established evidence.
+- A report's `DataItemTableView` and a caller's `SetTableView` apply mutually exclusive filters to the same field β `settableview-cannot-broaden-dataitemtableview`. Require both views or equivalent direct evidence; `SetTableView` alone is not a finding.
+- The value returned by `Report.RunRequestPage()` reaches `Report.Execute`, `Report.Print`, or `Report.SaveAs` without an empty-string cancellation check β `stop-when-runrequestpage-returns-empty-parameters`.
+
+Resolve layer conflicts per READ. When no reporting knowledge exists, emit `no-knowledge`; when knowledge exists but no article matches the changed report code, emit `completed` with no findings.
+
+## Action
+
+Evaluate every worklist article against the diff's report control flow and surrounding triggers.
+
+- Emit `major` for an unambiguous Anti Pattern that causes incorrect output, persisted side effects, or lost work.
+- Emit `minor` when code contradicts a Best Practice but the effect depends on unseen report or caller context.
+- Do not emit applicability-only information. A reporting article produces a finding only when changed code violates its normative guidance.
+
+Set confidence to `high` for locally visible control flow and `medium` when base-report behavior, callers, or missing context affect the conclusion. Domain-scoped agent findings follow DO's precision bar and remain capped at `minor`/`medium`.
+
+Provide `suggested-code` only when the replacement is complete, local, and unambiguous. Otherwise set `suggested-code-omission-reason`.
+
+Outcome selection follows DO: `completed`, `no-knowledge`, `not-applicable`, `partial`, or `failed`.
+
+## Output
+
+Output conforms to the DO findings-report contract. Every finding this skill emits MUST set `findings[].domain` to `"Reporting"`.
\ No newline at end of file
diff --git a/microsoft/skills/review/al-scm-review.md b/microsoft/skills/review/al-scm-review.md
new file mode 100644
index 0000000..8e22e2c
--- /dev/null
+++ b/microsoft/skills/review/al-scm-review.md
@@ -0,0 +1,122 @@
+---
+kind: action-skill
+id: al-scm-review
+version: 1
+title: AL Supply Chain Management review
+description: Reviews SCM inventory costing, item application, reservations, order tracking, item tracking, warehouse, transfer, and planning workflows in AL.
+inputs: [pr-diff, file-path, folder-path]
+outputs: [findings-report]
+bc-version: [all]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# AL Supply Chain Management review
+
+Reviews AL source against the `scm` knowledge domain. This leaf invokes no
+sub-skills and is composed by `al-code-review`. For a folder, inspect every
+relevant AL file; a folder supplies no historical baseline.
+
+## Source
+
+Apply Relevance's source gate before retrieval. For a relevant scope, use READ's
+**Bounded retrieval for review skills** with `-Domain scm` and
+`-Technologies @('al')`. Consume every catalog page across enabled layers,
+preserving exact paths and applicability. Select from metadata, then read only
+worklisted complete articles. Entry owns index preparation; the leaf does not
+rebuild. Unavailable/invalid helpers or indexes use READ's bounded native-read
+fallback, never a success-shaped empty result.
+
+## Relevance
+
+Resolve changed record/codeunit types, source tables, publishers and calls.
+Gate on code that mutates or posts inventory, application, reservation,
+tracking, warehouse, transfer or planning state, or makes a supply/demand
+availability decision. Stock displays and other read-only queries without
+that decision do not pass the gate. Names, comments, captions, an `Item`
+reference or a broad `ApplicationArea` alone are not signals.
+If no SCM surface remains, return `not-applicable` with zero coverage
+and no article-body retrieval; in mixed diffs, retain only relevant procedures
+and their visible supporting context.
+
+SCM owns `"Item Ledger Entry"`, `"Value Entry"`, `"Capacity Ledger Entry"`,
+`"Warehouse Entry"` and inventory posting/application records. Pure `"G/L Entry"`,
+`"Cust. Ledger Entry"`, `"Vendor Ledger Entry"`, `"Detailed Cust. Ledg. Entry"`,
+`"Detailed Vendor Ledg. Entry"`, `"VAT Entry"` and financial-only posting
+mutations belong to Finance. They remain outside SCM even if Finance is absent
+or disabled; do not reclaim them as SCM agent findings. Ownership is not a
+claim that every owned surface already has a dedicated article.
+
+Apply READ's frontmatter filters using the target BC major version from
+application dependency/host context (not the extension version), AL, known
+localization and actual task/object application areas. Omitted context stays
+unknown, not `[all]`; unknown areas alone do not exclude codeunits/subscribers.
+Retain conditional articles only when configured, cap their findings at
+`medium`, and name every unknown dimension.
+
+## Worklist
+
+Extract resolved object/type names, quoted fields, methods, enum members and
+publishers. Normalize these and catalog keywords by lowercasing invariantly,
+replacing punctuation/whitespace runs with one hyphen and trimming hyphens:
+`"Item Ledger Entry"` becomes `item-ledger-entry`; `RunWithCheck` becomes
+`runwithcheck`. Match whole tokens/phrases, not identifier substrings.
+
+Select matching keywords or catalog topics only for the same source surface
+**and operation**. The following cues resolve slugs to actual enabled catalog
+paths; they select articles, not findings. Facts and exceptions stay in articles.
+
+| Changed source surface and operation | Article slug |
+| --- | --- |
+| `"Item Ledger Entry"`/`"Value Entry"` transaction writes, or a standalone item-journal quantity/value posting entry point | `post-item-ledger-changes-through-item-journals` |
+| Revaluation `"Item Journal Line"` with `"Inventory Value Per"` or `"Partial Revaluation"`, and its line/batch posting calls | `post-revaluation-through-the-item-journal-batch` |
+| `"Item Application Entry"` relationship/quantity mutation, or `UnApply`, `ReApply`, `RedoApplications`, `CostAdjust` in an application-correction flow | `change-item-applications-through-posting-routines` |
+| Binding-reservation cancellation: `"Reservation Entry"` status, delete/quantity/source edits, `CancelReservation`, or source reservation-lifecycle calls | `cancel-reservations-through-reservation-management` |
+| Tracking source conversion/partial movement: `"Sales Line-Reserve"`, `TransferSaleLineToSalesLine`, `TransferReservEntry`, `CopyItemTracking`, or `"Reservation Entry"`/`"Tracking Specification"` source/quantity writes | `transfer-item-tracking-through-source-reservation-codeunits` |
+| Registered warehouse quantity/physical-adjustment synchronization, `"Directed Put-away and Pick"`, `"Adjustment Bin Code"`, `"Warehouse Adjustment"`, or `"Calculate Whse. Adjustment"` and the resulting item-journal posting | `reconcile-warehouse-adjustments-with-the-item-ledger` |
+| `"Transfer Header"`/`"Transfer Line"` shipment/receipt completion, transfer posting publishers, in-transit/document-link changes, or item-journal posting presented as transfer-order completion | `post-transfers-through-shipment-and-receipt-codeunits` |
+| `Inventory`, `CalcQtyAvailableToPromise`, or stock sums used in a dated supply/demand promise, including changed location/variant/date filters and source-demand context | `use-date-aware-availability-for-promising` |
+| Direct assignment to `Quantity`, `"Unit of Measure Code"`, `"Qty. per Unit of Measure"`, or a `(Base)` quantity field on a persisted or posted item journal, sales, purchase, or transfer line, or a line quantity compared with a base-unit inventory value | `derive-base-quantities-through-the-line-unit-of-measure` |
+| `"Requisition Line"` action-message execution, accepted planning suggestions, `"Req. Wksh.-Make Order"`, `CarryOutBatchAction`, or linked supply creation/change plus requisition-line deletion | `carry-out-requisition-actions-through-the-standard-workflow` |
+
+Route clean supported calls through the same cues, not just suspicious writes.
+Resolve actual normative conflicts per READ, preserving additive layers and
+recording `layer-precedence`/`configuration` suppressions, not noncandidates.
+Retrieve exact paths in ordinal chunks of at most eight, consume every
+continuation, and never impose a top-eight cutoff. Samples use exact READ links.
+
+## Action
+
+Evaluate every opened article's normative facts, scope and exclusions against
+visible persistence, caller contract, document state and operation. Emit only
+concrete violations with business consequences and supported remediation; a
+declaration, valid alternative or unseen caller is not evidence of a defect.
+
+- Use `major` for material SCM defects, `minor` for narrower best-practice
+ conflicts, and `blocker` only for an article-established platform guarantee.
+ Applicability alone produces no finding. High confidence requires unambiguous
+ evidence and known applicability; inference/conditional applicability caps it
+ at `medium`.
+- Apply DO's single-owner deduplication. Equivalent findings for the same
+ inventory-originated posting bypass and correction have one SCM primary
+ owner, even when financial records are downstream. Prefer the most specific
+ SCM article and retain other applicable references as supporting evidence.
+ Distinct independent financial defects remain Finance; do not duplicate them.
+- Agent findings stay strictly SCM-scoped under DO's precision bar, with
+ `references: []`, an `agent:` id and `minor`/`medium` ceilings. Generic AL and
+ other domains' concerns remain outside this leaf.
+- Supply literal `suggested-code` only for a complete, local, unambiguous fix,
+ not a sample call that omits workflow setup/source identity. Explain omitted
+ mechanical-looking fixes with `suggested-code-omission-reason`.
+
+Outcome selection follows DO, including accurate coverage and reasons for
+`partial`/`failed`. No surviving applicable corpus is `no-knowledge`; an existing
+corpus with no matching operation is `completed` with an empty worklist.
+
+## Output
+
+Output conforms to the DO findings-report contract and shared schema. Every
+finding MUST set `domain` to `"Supply Chain Management"`. Knowledge-backed ids
+equal the primary opened article's exact catalog path. The coordinator, not
+this leaf, sets `from-sub-skill`.
diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md
index 8472afe..3d70173 100644
--- a/microsoft/skills/review/al-security-review.md
+++ b/microsoft/skills/review/al-security-review.md
@@ -4,7 +4,7 @@ id: al-security-review
version: 1
title: AL security review
description: Reviews AL source changes against security guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `security` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `security` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/security/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain security`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -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 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`, `TransferFields`, `Codeunit.Run`, `Access = Internal`, `internalsVisibleTo`, `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`, `SetFilter`, `ServiceEnabled`, `PageType = API`, `QueryType = API`, `permissionset`, `Web Services`).
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.
@@ -49,6 +49,7 @@ 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.
+- When a page/query is registered in Web Services, declares `PageType = API`/`QueryType = API`, a codeunit is registered in Web Services, or `[ServiceEnabled]` is added to a page procedure, and no permission set in the app grants a matching `page "..." = X` / `query "..." = X` / `codeunit "..." = X` entry for that specific object β use `exposed-objects-must-be-in-a-permission-set.md`. The anti-pattern is the exposed object missing its own execute entry, even when the underlying table's `tabledata` permissions look complete; require repository-level permission-set context, since one file cannot prove an entry is absent elsewhere in the app.
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`.
@@ -129,7 +130,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
}
```
-The empty-corpus case β BCQuality's state until security knowledge files land β produces:
+When no applicable security knowledge is available, the report is:
```json
{
diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md
index bffef4d..2aaf902 100644
--- a/microsoft/skills/review/al-style-review.md
+++ b/microsoft/skills/review/al-style-review.md
@@ -3,8 +3,8 @@ kind: action-skill
id: al-style-review
version: 1
title: AL style review
-description: Reviews AL source changes against naming, labelling, and code-convention guidance from BCQuality.
-inputs: [pr-diff, file-path]
+description: Reviews AL source changes against naming, labelling, localization, and code-convention guidance from BCQuality.
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,13 +16,13 @@ application-area: [all]
Reviews AL source changes against the `style` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-Style findings cover AL conventions that CodeCop and similar analyzers partially enforce β label suffixes, API page naming, temporary-variable prefixes, label properties, named invocations, `FieldCaption`/`TableCaption` in user messages, `OptionCaption` pairing, Error-parameter passing, `this` keyword, required parentheses, file-naming. Use together with a formal analyzer; this skill adds BCQuality's remedial-knowledge explanations of why each rule exists.
+Style findings cover AL conventions that require contextual judgment β API page naming, temporary-variable prefixes, label semantics, date-formula localization, named invocations, `FieldCaption`/`TableCaption` in user messages, error-parameter handling, and file naming. Mechanical compiler and analyzer rules are intentionally outside this skill; run the consuming app's configured analyzers separately.
-An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `style` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/style/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain style`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -40,8 +40,8 @@ Discard files that are not applicable. Retain conditionally applicable files onl
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
- Changed AL objects β especially API pages (`PageType = API`), tables and pages declaring Labels/TextConsts, codeunits issuing `Error`/`Message`/`Confirm`, and any file whose name violates the `..al` convention.
-- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, option fields, error-handling call sites, and codeunit-internal method calls.
-- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `OptionMembers`, `OptionCaption`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `this.`, `StrSubstNo`).
+- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, option fields, `DateFormula` declarations and their `Evaluate` call sites, error-handling call sites, API declarations, and codeunit-internal method calls.
+- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `DateFormula`, `Evaluate`, `CalcDate`, `OptionMembers`, `OptionCaption`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `this.`, `StrSubstNo`, `namespace`, `using`, `var `).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object or declaration. Read an article's full file β its `## Best Practice` / `## Anti Pattern` bodies β only after it makes the worklist; candidate selection uses the index alone.
@@ -50,7 +50,18 @@ Do not worklist `temporary-variable-temp-prefix.md` for an event publisher param
Apply these high-signal mappings before fuzzy topic ranking:
- A `Label` or `TextConst` contains multiple or ambiguous placeholders but has no `Comment`, or its Comment does not explain every placeholder β `label-comment-explains-placeholders.md`. A single placeholder whose meaning is explicit in the text, such as `Customer %1`, is allowed without a Comment and must not be flagged.
+- A normal two-argument `Evaluate` has a resolved `DateFormula` destination and a hard-coded non-angle-bracket date-formula literal, directly or through a visible constant β `dateformula-evaluate-needs-language-independent-literals.md`. Do not use this cue for dynamic/localized external input, already invariant `<...>` input, or direct `CalcDate(Text, ...)` calls.
- `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)`.
+- A new or changed comment restates what the adjacent code already makes obvious from its own names and structure (a comment that just repeats a variable/field/method name in prose) rather than explaining a non-obvious constraint, invariant, or workaround β `al-comments-must-not-restate-what-code-already-shows.md`. A comment absent entirely is not this anti-pattern; only a present-but-redundant comment is.
+- A `page`/`pageextension` adds or changes a procedure body that performs a calculation, validation, or record mutation belonging to a business operation reused across entry points, rather than presentation-specific state or a call into a codeunit β `pages-must-not-contain-business-logic.md`. A page calling a codeunit procedure, or a page's own presentation-only state and formatting, is not this anti-pattern; nor is a data invariant that belongs on the table itself.
+- Changed source files are added under an object-type folder (`Tables/`, `Pages/`, `Codeunits/`, etc.) in a repository whose existing structure is predominantly feature-based, or vice versa β `source-organized-by-feature-not-object-type.md`. The anti-pattern is inconsistency with the repository's own established convention, not the choice of either scheme; a repository consistently organized by object type throughout is not a violation. Require repository-level folder context; a single new file's path cannot prove the project's convention alone.
+- A new `.app` build artifact appears at the project root or another unversioned/arbitrary location, or is added to source control alongside the AL source that produced it β `al-build-output-must-not-pollute-project-root.md`. A deliberate `--outfolder`/`outputPath` destination added to `.gitignore` is the compliant shape, not the signal to flag.
+- A new or renamed AL identifier (variable, procedure, parameter, field, object, enum value, or label identifier) contains non-English words β `al-identifiers-english.md`. A caption, tooltip, or other user-facing text value in a non-English language is not this anti-pattern; only the identifier itself is in scope.
+- A field or variable is typed `Boolean` and its two possible values are genuinely named domain alternatives (a status pair like Inbound/Outbound, Debit/Credit, Buy/Sell) rather than a true/false predicate, or an `Option`/`Enum`/`Integer` models a domain concept that is intrinsically a yes/no flag β `binary-choice-must-be-boolean.md`. The signal is a semantic mismatch between the type and the domain concept, not the current number of states.
+- A field or variable is typed `Integer` with the meaning of each value tracked only in a comment, or an `Enum` with more than two members is proposed as `Boolean`-like β `fixed-choice-set-must-use-enum-not-integer.md`. Do not flag a genuinely two-state `Enum`/`Option` for having "too few" members; that overlaps `binary-choice-must-be-boolean.md` instead when the domain is a true/false predicate.
+- A new `using` directive is added for an existing AL object without the diff also showing that object's own `namespace` declaration or a symbol-package lookup backing the choice β `namespace-must-be-verified-from-source.md`. Require repository/dependency context; a single new `using` line cannot itself prove whether the namespace was verified or guessed.
+- An intrinsic/built-in AL function call (`MESSAGE`, `ERROR`, `CONFIRM`, `STRSUBSTNO`, etc.) is written in ALL-CAPS or another non-PascalCase form β `intrinsic-al-functions-must-use-modern-casing.md`.
+- A procedure call passes a literal or a computed expression (not a caller-scope variable) to a parameter position the callee declares `var` β `var-parameters-require-an-addressable-variable.md`. This is a compile-time-guaranteed shape; flag it only when the callee's declared signature is visible in the diff or resolvable from context.
Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions.
@@ -60,7 +71,7 @@ When the post-conflict worklist is empty because no applicable style knowledge e
For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Style findings rarely reach `blocker` β reserve it for cases where the knowledge file documents a platform-level requirement (for example, API page property constraints the OData runtime rejects). Most style findings are `minor` or `info`; egregious misuse (`Error` with pre-built Text losing translation and telemetry classification) may reach `major`.
-Severity calibration β a formal analyzer already flags the mechanical presence/naming conventions (the `this` keyword AA0248, approved label suffixes AA0074, variable-declaration order by type AA0021, a missing `ToolTip`, required parentheses). On those, BCQuality's value is the *explanation* of why the rule exists, not a second gate; emit them at `info` so a consumer that gates on severity does not re-flag what CodeCop/AppSourceCop already reports. Reserve `minor` for style issues with concrete downstream impact the analyzer does not catch β lost translation or telemetry classification from a string-built `Error`, an `OptionCaption` that does not match its `OptionMembers`, or a misleading named invocation. A procedure-local `Label` is valid and is not a correctness or localization finding; an explicit repository preference for object scope is at most low-severity maintainability guidance. This keeps the domain's default output advisory and prevents analyzer-redundant noise from competing with substantive review.
+Severity calibration β reserve `minor` for style issues with concrete downstream impact that deterministic tooling does not establish, such as lost translation or telemetry classification from a string-built `Error` or a misleading named invocation. A procedure-local `Label` is valid and is not a correctness or localization finding; an explicit repository preference for object scope is at most low-severity maintainability guidance. Do not rediscover or report mechanical compiler or analyzer diagnostics, even at `info`.
Set `confidence` to:
@@ -70,7 +81,7 @@ Set `confidence` to:
After evaluating each worklist entry, also consider whether the diff exhibits a style defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain β emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a clear, widely-accepted AL style violation with a concrete basis a knowledgeable BC reviewer would agree on β steelman it first and drop personal preference, speculation, and any single defensible formatting choice among several; when in doubt, omit. The scope is strictly style β naming, labelling, formatting, and analyzer-adjacent conventions. A correctness, logic, data-integrity, or contract defect is NOT a style finding even when it can be reworded as a convention: a method that mutates a shared `Record`'s filters, an unfiltered `DeleteAll`, a violated interface contract, or a wrong boolean guard are behavioural defects, not conventions β do not emit them here under a style framing. If a specific domain leaf covers the concern (performance, security, error-handling, β¦) it belongs there; if no knowledge file in any domain covers it, it belongs to the `al-code-review` super-skill's cross-cutting self-review agent channel (`from-sub-skill: "agent"`, `severity` capped at `minor`), not to this leaf. A reliable test: if you cannot cite a style `## Best Practice`/`## Anti Pattern` for the concern, it is very likely not a style finding. Before emitting, check the worklist for a knowledge file that matches the candidate β if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
-For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement β no diff markers, no fences, no commentary β that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add a missing contextual `ToolTip`, replace a string-concatenated `Error` with a Label-backed call, or correct an API naming property whose intended value is clear). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement β no diff markers, no fences, no commentary β that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
@@ -91,20 +102,20 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
"skill": { "id": "al-style-review", "version": 1 },
"outcome": "completed",
"summary": {
- "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 1 },
+ "counts": { "blocker": 0, "major": 0, "minor": 1, "info": 0 },
"coverage": { "worklist-size": 1, "items-evaluated": 1 }
},
"findings": [
{
- "id": "microsoft/knowledge/style/label-suffix-approved-list.md",
- "severity": "info",
- "message": "A Label named Text000 has no approved suffix (Msg/Err/Qst/Tok/Lbl/Txt). Per the referenced CodeCop AA0074 guidance, every Label and TextConst carries a suffix indicating its consuming call.",
+ "id": "microsoft/knowledge/style/label-comment-explains-placeholders.md",
+ "severity": "minor",
+ "message": "The label has two ambiguous placeholders but no Comment explaining what each value represents to translators.",
"location": {
"file": "src/Sales/PostingRoutines.Codeunit.al",
"line": 42
},
"references": [
- { "path": "microsoft/knowledge/style/label-suffix-approved-list.md" }
+ { "path": "microsoft/knowledge/style/label-comment-explains-placeholders.md" }
],
"confidence": "high",
"domain": "Style"
diff --git a/microsoft/skills/review/al-telemetry-review.md b/microsoft/skills/review/al-telemetry-review.md
index 4a758f0..1c2046d 100644
--- a/microsoft/skills/review/al-telemetry-review.md
+++ b/microsoft/skills/review/al-telemetry-review.md
@@ -4,7 +4,7 @@ id: al-telemetry-review
version: 1
title: AL telemetry review
description: Performs an AL telemetry review against guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `telemetry` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Telemetry findings are narrow by design β they apply when the diff emits, wraps, or changes custom telemetry through `Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, or related telemetry helpers. The skill returns `not-applicable` when none of those apply.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Telemetry findings are narrow by design β they apply when the review scope emits, wraps, or changes custom telemetry through `Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, or related telemetry helpers. The skill returns `not-applicable` when none of those apply.
## Source
-Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `telemetry` as this skill's candidate set across every enabled Microsoft, community, and custom layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/telemetry/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain telemetry`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md
index 8bad734..c42fe85 100644
--- a/microsoft/skills/review/al-testing-review.md
+++ b/microsoft/skills/review/al-testing-review.md
@@ -4,7 +4,7 @@ id: al-testing-review
version: 1
title: AL testing review
description: Performs an AL testing review against guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `testing` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Testing findings are narrow by design β they apply when the diff touches test codeunits, test runners, test methods, handlers, assertions, or fixture construction. The skill returns `not-applicable` when none of those apply.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Testing findings are narrow by design β they apply when the review scope contains test codeunits, test runners, test methods, handlers, assertions, or fixture construction. The skill returns `not-applicable` when none of those apply.
## Source
-Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `testing` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/testing/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain testing`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -39,18 +39,30 @@ Narrow the relevant files to the subset that applies to the changes under review
- The changed AL object names and types β especially codeunits with `Subtype = Test`, test runner codeunits with `TestIsolation`, test libraries, and codeunits that define UI handlers.
- The changed methods and attributes, weighted toward `[Test]`, `[TransactionModel(...)]`, `[TestPermissions(...)]`, `[HandlerFunctions(...)]`, handler attributes, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, fixture initialization, and test-library calls.
-- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `Subtype = TestRunner`, `TestIsolation`, `TestPermissions`, `Restrictive`, `NonRestrictive`, `Disabled`, `Permissions Mock`, `Library - Lower Permissions`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `SendNotificationHandler`, `RecallNotificationHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Init`, `Insert`).
+- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `Subtype = TestRunner`, `TestIsolation`, `TestPermissions`, `Restrictive`, `NonRestrictive`, `Disabled`, `Permissions Mock`, `Library - Lower Permissions`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `SendNotificationHandler`, `RecallNotificationHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Initialize`, `IsInitialized`, `OnTestInitialize`, `LibrarySetupStorage`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Library - Utility`, `LibraryUtility`, `GenerateGUID`, `GenerateRandomCode`, `TestPage`, `.Visible(`, `.Enabled(`, `.Editable(`, `OpenNew`, `OpenView`, `OpenEdit`, `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`.
+- A new or changed `[Test]` procedure is added, whether or not it already carries `[FEATURE]`/`[SCENARIO]`/`[GIVEN]`/`[WHEN]`/`[THEN]` tags β `test-feature-scenario-tags`. A procedure with no tags at all, or a generic name like `Test1`, is the anti-pattern signal; presence of the tags is the compliant shape, not the thing to search for.
+- A test codeunit calls `TestPage` methods (`OpenNew`, `OpenView`, `OpenEdit`) alongside `[Test]` procedures in the same codeunit that call business-logic procedures directly with no `TestPage` involved β `ui-test-codeunit-naming`. The anti-pattern signal is both kinds of test mixed into one codeunit (or, on a project using the `_UT` convention, a UI-layer codeunit missing the suffix); a codeunit containing only `TestPage`-driven tests is not itself a violation.
+- A `[GIVEN]`-tagged setup precedes a posting call or report execution and does not visibly set up posting-group/VAT setup records, an explicit date, or (for a report test) both an included and an excluded record β `given-blocks-must-cover-full-precondition-chain`.
+- A test procedure contains more than one `[WHEN]` block, or more than one distinct action not labelled `[GIVEN]`, without the procedure name declaring a flow/defect-then-fix shape β `test-one-when-per-test`.
+- A `BCPT*` scenario codeunit is added and the PerformanceTest app's only other scenario codeunits are copies of Microsoft's shipped BCPT samples (`BCPT Create Customer`, `BCPT Create Item Journal`, `BCPT Post GL Entries`, etc.) with no scenario exercising the extension's own codeunits, FlowFields, or pages β `bcpt-scenarios-must-be-app-specific`.
- 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 codeunit's `Initialize` procedure exits on `IsInitialized` before per-test reset such as `LibraryVariableStorage.Clear`, `LibrarySetupStorage.Restore`, or `LibraryTestInitialize.OnTestInitialize`, or a `[Test]` method in a codeunit using that pattern does not call `Initialize()` first β `reset-per-test-state-before-the-isinitialized-guard`.
+- `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`. Exclude `asserterror Assert.IsTrue(...)` / `asserterror Assert.IsFalse(...)` only when it is used solely to invert the guarded call's Boolean result (the same condition `use-assert-isfalse-not-asserterror-for-boolean-checks` cues on below, which wins for that shape) β not when the test expects the guarded Boolean-returning call itself to raise an error, which this rule still owns even though it happens to wrap an `Assert.IsTrue`/`IsFalse` call. Also exclude a trailing `asserterror Error(...)` used purely as an end-of-test rollback sentinel after a lazy `Initialize()` fixture already committed β that shape belongs to `commit-shared-test-fixture-inside-lazy-initialize`, which wins for it; the sentinel's own error text is not meant to be asserted against.
+- `asserterror` wraps `Assert.IsTrue(BooleanExpression, ...)` (or the `IsFalse` mirror) solely to invert the boolean result of the guarded call, rather than to assert that call itself raises an error β `use-assert-isfalse-not-asserterror-for-boolean-checks`.
+- A shared/lazy `Initialize()`-style fixture helper creates fixture data without a following `Commit()`, in a test method whose body later forces its own rollback (for example `asserterror Error(...)` used for end-of-test cleanup) β `commit-shared-test-fixture-inside-lazy-initialize`. The presence of `Commit()` after the fixture is the compliant shape, not the signal to look for; the missing-`Commit()` shape combined with a later deliberate rollback is the anti-pattern. Require runner/repository context for the `TestIsolation` value: a standalone test file cannot prove which runner executes it, and under `Function`-level isolation this whole pattern is moot regardless of `Commit()` β do not raise the finding when the executing runner's `TestIsolation` is known to be `Function`.
+- Changed code subscribes to `OnAfterRemoveTableRelation`, calls `RemoveTableRelation`, or references `Codeunit "Table Relation Test"`/134926 β `table-relation-test-exclude-known-invalid-relations-via-event`.
+- Test fixture code assigns a hardcoded literal to a primary-key field or a field the test relies on as a unique lookup identifier, hand-builds a "unique" value for such a field (string concatenation, a counter, `Format(CurrentDateTime)`), or truncates `LibraryUtility.GenerateGUID()`'s result with `CopyStr` for such a field shorter than 10 characters β `use-generateguid-for-unique-test-fixture-values`. Calling `GenerateGUID()` untruncated into a full-length field, `GenerateRandomCodeWithLength` for a shorter field needing real verified uniqueness, or `GenerateRandomCode20` specifically for a `Code[20]` field, is the compliant shape, not the signal to flag. `GenerateRandomCode20` is not a substitute for `GenerateRandomCodeWithLength` on a shorter field β it truncates `GenerateGUID()`'s sequential value down to the field's length by keeping the *leftmost* characters, which change the slowest, so retries against a short field can churn through the same truncated prefix far longer than `GenerateRandomCodeWithLength`'s equivalent. A hardcoded or deterministic value in an ordinary descriptive field is not this anti-pattern β that field carries no uniqueness constraint. Do not claim `GenerateRandomCode` (without `WithLength`/`20`) or `GenerateRandomXMLText` verify uniqueness against the real table, or that `GenerateRandomCode` is collision-free even within one test run for a short field β none of that is true.
+- A test asserts against a `TestPage` field's `.Visible()` or `.Enabled()` β `use-testpage-visible-enabled-to-verify-field-ui-state`. When the assertion is against `.Editable()`, or the page is opened with `OpenEdit()` specifically to check editability β `use-testpage-editable-to-verify-field-editability`.
- A test path raises UI and `[HandlerFunctions(...)]` does not match the invoked handlers, or the test has no meaningful evidence of the UI result (for example, it treats a Boolean set before the action as proof of success) β `ui-handlers-in-tests`. A capture/reset/assert-after-`RunModal` pattern is valid. Enqueue/dequeue and `AssertEmpty` are required only when order, count, text, replies, or a scripted sequence is part of the contract. Only nonoptional handlers have to execute: a listed handler declared `[SendNotificationHandler(true)]` or `[RecallNotificationHandler(true)]` is optional by design, so do not treat it as unmatched when the run never raises the notification.
+- A test's `[GIVEN]`/setup looks up a hardcoded code/number/name assumed to already exist instead of creating it, leaves a mandatory field on a created record empty, uses a value that doesn't satisfy the scenario's own explicit length/format requirement (for example a truncation test whose value never exceeds the field), or a scenario-defining value (amount, quantity, percentage, date, threshold, rounding precision) is generated/randomized instead of an explicit chosen value β `test-data-must-be-random-and-complete`. Generating incidental fixture values (identifiers, names, descriptions) via the standard library codeunits is the compliant shape, not the signal to flag, and neither is a short-but-valid value in an otherwise-unremarkable field.
Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.
diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md
index c0af5af..e5f237f 100644
--- a/microsoft/skills/review/al-ui-review.md
+++ b/microsoft/skills/review/al-ui-review.md
@@ -4,7 +4,7 @@ id: al-ui-review
version: 1
title: AL UI and accessibility review
description: Reviews AL page and control add-in UI files against UI text, caption, tooltip, and accessibility guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al, javascript]
@@ -18,11 +18,11 @@ Reviews AL page source and control add-in UI files against the `ui` knowledge do
UI findings apply to page files β files that declare `PageType = ...`, including `*.Page.al` under the standard file-naming convention β and to JavaScript/CSS/HTML files that implement Business Central control add-ins, including their client-service communication. The skill returns `not-applicable` when the diff contains no page or control add-in changes.
-An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `ui` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/ui/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain ui`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -40,11 +40,19 @@ Discard files that are not applicable. Retain conditionally applicable files onl
Narrow the relevant files to the subset that applies to the changes under review.
- **UI-file filter.** UI review applies to files declaring `page`, `pageextension`, or `pagecustomization`, and to JavaScript/CSS/HTML that implements a control add-in's rendering or Business Central communication. When the diff contains no such files, return `outcome: "not-applicable"` without evaluating knowledge files.
+- A new or changed page's name/suffix, primary-key handling, `CardPageID`, `SubPageLink`, `AutoSplitKey`, or `UsageCategory` doesn't match the conventions of its own declared `PageType` β `page-design-must-match-bc-page-type-conventions.md`. A Card page over a composite-key table that supplements a master record, or a supporting/subpage/dialog page intended only to be reached through another workflow and correctly omitting `UsageCategory`, is not this anti-pattern on its own; check whether the page is actually mixing conventions or is meant as a searchable entry point before flagging.
- 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).
+- 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`, `fieldgroups`, `DropDown`, `UpdatePropagation`, `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, `CardPageID`, `AutoSplitKey`, `PageType = Card`, `PageType = List`, `PageType = Worksheet`, `PageType = Document`, `PageType = RoleCenter`, `Enabled`, `Visible`, `Editable`, `in [`, `AccessByPermission`, `ReadPermission`, `WritePermission`).
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.
+Apply these high-signal mappings before fuzzy topic ranking:
+
+- A tableextension adds a field to `DropDown` while the corresponding lookup-page control remains `Visible = false` β `dropdown-fieldgroup-respects-lookup-page-visibility`.
+- An editable page part affects a total, FlowField, or FactBox on the parent but does not set `UpdatePropagation = Both` β `updatepropagation-both-refreshes-main-page`.
+- A page or pageextension `Enabled`, `Visible`, `Editable`, or `StyleExpr` value contains an `in [...]` list (compiler: "InListExpression is not valid for client expressions", AL0573 or AL0322), or replaces one with a procedure call β `page-client-expression-must-not-use-in-list`. Plain `=`/`<>` comparisons joined with `and`/`or`, and `in [...]` inside a trigger or procedure body, are valid; do not flag them.
+- A `RoleCenter` page, or a pageextension whose target is a Role Center, gates a part, action, or field by binding `Visible` or `Enabled` to a procedure that tests a permission, or declares a procedure (AL0569 "A page of type Role Center cannot have procedures", AL0573) β `rolecenter-permission-gating-must-use-accessbypermission`. The target page name is not reliable evidence of its type; confirm it is a Role Center from its `PageType` or the AL0569 diagnostic. Setup- or feature-flag gating inside the part page is not this pattern.
+
Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions.
When the post-conflict worklist is empty because no applicable UI knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable UI knowledge matched the page changes, emit `outcome: "completed"` with an empty `findings` array.
diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md
index 667ea32..f2c8b1d 100644
--- a/microsoft/skills/review/al-upgrade-review.md
+++ b/microsoft/skills/review/al-upgrade-review.md
@@ -4,7 +4,7 @@ id: al-upgrade-review
version: 1
title: AL upgrade review
description: Reviews AL source changes against upgrade-code and migration guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `upgrade` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Upgrade findings are narrow by design β they apply when the diff touches upgrade codeunits, install codeunits, table schema, enums, or objects under migration namespaces. The skill returns `not-applicable` when none of those apply.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Upgrade findings are narrow by design β they apply when the review scope contains upgrade codeunits, install codeunits, `Feature Data Update` implementations, table schema, enums, or objects under migration namespaces. 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 `upgrade` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/upgrade/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain upgrade`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -37,12 +37,16 @@ Discard files that are not applicable. Retain conditionally applicable files (an
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
-- The changed AL object names and types β especially codeunits with `Subtype = 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 `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`).
+- The changed AL object names and types β especially codeunits with `Subtype = Upgrade` or `Subtype = Install`, codeunits implementing `Feature Data Update`, tables and tableextensions adding or changing fields, enums and enumextensions, and objects under `Hybrid*`/`Migration`/`Upgrade` namespaces.
+- The changed triggers and procedures, weighted toward `OnCheckPreconditionsPerCompany`/`PerDatabase`, `OnUpgradePerCompany`/`PerDatabase`, `OnValidateUpgradePerCompany`/`PerDatabase`, `OnInstallAppPerCompany`/`PerDatabase`, the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers, the `UpdateData`/`AfterUpdate` methods of `Feature Data Update` implementations, 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`, `ModuleInfo`, `AppVersion`, `DataVersion`, `NavApp.GetCurrentModuleInfo`, `ExecutionContext`, `ChangeCompany`, `Feature Data Update`, `UpdateData`, `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.
+- Worklist `install-and-upgrade-codeunits-have-no-order.md` when a change adds multiple install or upgrade codeunits whose same-phase triggers share state or depend on one another.
+- Worklist `no-changecompany-in-upgrade.md` when `ChangeCompany` with a company-name argument appears in an upgrade codeunit, in a helper reachable from its upgrade triggers, or in the `UpdateData` or `AfterUpdate` method of a `Feature Data Update` implementation or a helper reachable from them. Trace that reachability with the same call-graph and confidence rules as the check-only rule. Do not worklist it for `ChangeCompany` reachable only from `IsDataUpdateRequired` or `ReviewData`; that read-only preflight is permitted.
+- Worklist `appversion-meaning-depends-on-execution-context.md` when install or upgrade code branches on `ModuleInfo.AppVersion()` or confuses it with `DataVersion()`.
+- An upgrade tag's existence check (`HasUpgradeTag`) is nested inside another tag's guarded body, or one tagged procedure performs two or more functionally unrelated migrations (different tables, fields, or concerns) under a single tag, or one procedure mixes the gated logic for more than one distinct upgrade tag β `upgrade-tag-logic-must-not-nest-deeply.md`. Do not flag record loops or business-data safety guards (corruption checks, redundant-write checks, or other conditions) that serve the single migration the tag represents, however many `if` levels they take β that is the compliant shape the article explicitly permits.
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.
@@ -74,7 +78,7 @@ Outcome selection:
- `completed` β the skill evaluated every worklist item.
- `no-knowledge` β no applicable upgrade knowledge survived filtering.
-- `not-applicable` β the diff touches no upgrade, install, schema, or enum surface.
+- `not-applicable` β the diff touches no upgrade, install, feature data update, schema, or enum surface.
- `partial` β a budget was hit before the worklist was exhausted.
- `failed` β an unrecoverable error occurred.
diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md
index cfa6fdd..6d7d71a 100644
--- a/microsoft/skills/review/al-web-services-review.md
+++ b/microsoft/skills/review/al-web-services-review.md
@@ -3,8 +3,8 @@ kind: action-skill
id: al-web-services-review
version: 1
title: AL web services review
-description: Reviews AL API surfaces and webhook integration handlers against web-services guidance from BCQuality.
-inputs: [pr-diff, file-path]
+description: Reviews AL API surfaces, outbound HTTP integrations, and webhook handlers against web-services guidance from BCQuality.
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al, javascript]
@@ -14,13 +14,13 @@ application-area: [all]
# AL web services review
-Reviews AL source changes against the `web-services` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
+Reviews AL source changes against the `web-services` knowledge domain in BCQuality and emits a findings report. This includes inbound API surfaces, outbound HTTP integrations, and webhook lifecycle code. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once β the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone β see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint β exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `web-services` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/web-services/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain web-services`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -39,8 +39,13 @@ Narrow the relevant files to the subset that applies to the changes under review
- 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.
+- Outbound HTTP integration code that constructs or sends requests, captures a client method's optional Boolean result, checks an `HttpResponseMessage`, or reads and parses response content.
+- Integration code that converts values to or from exchanged text: `Format` or `Evaluate` on a request URL, body, or file line, and `JsonToken`/`JsonValue` conversions of payload properties.
- 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`).
+- An API page (`PageType = API`) is added or changed and its `InsertAllowed`/`ModifyAllowed`/`DeleteAllowed` properties are left at their default `true` (or explicitly set `true`) for an operation the endpoint's stated purpose does not need β `api-page-least-privilege-write-access.md`. The signal is an operation left enabled beyond what the endpoint's own described purpose requires, not the mere presence of these properties; a page that genuinely needs full CRUD and grants it deliberately is not a violation.
+- An API page (`PageType = API`) with `InsertAllowed = true` lists a field in `ODataKeyFields` and that same field control sets `Editable = false` β `api-page-key-fields-must-be-editable-on-insert.md`. A system-generated key such as `SystemId` marked read-only is not this anti-pattern.
+- An API page exposes a field via `Rec` directly, and that field is a stored value computed from other fields inside an `OnValidate` trigger rather than a FlowField recalculated in `OnAfterGetRecord` β `stored-derived-fields-must-not-be-exposed-directly.md`. Exposing a genuine FlowField, or a value already recalculated in `OnAfterGetRecord`, is not this anti-pattern.
+- 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`, `HttpClient`, `HttpRequestMessage`, `HttpResponseMessage`, `Get`, `Post`, `Put`, `Delete`, `Send`, `IsSuccessStatusCode`, `HttpStatusCode`, `Content`, `ReadAs`, `JsonObject`, `JsonToken`, `JsonValue`, `AsValue`, `IsNull`, `SelectToken`, `Format`, `Evaluate`, `XmlDocument`).
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.
@@ -56,7 +61,14 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice`
- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape.
- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present.
-For 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.
+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`.
+
+For outbound HTTP calls, apply these targeted checks:
+
+- When code captures the optional Boolean result of `HttpClient.Get`, `Post`, `Put`, `Delete`, or `Send`, require the failed branch to stop before status, headers, or content are accessed. Do not report a call that omits the Boolean result: that form intentionally lets the runtime raise an error when the request cannot execute.
+- After platform success, require `IsSuccessStatusCode()` or an explicit acceptable `HttpStatusCode()` check before response content is interpreted as a success payload. Do not report code that reads a bounded non-success body solely for diagnostics and keeps it out of the success parsing path.
+
+Do not emit generic HTTP or REST advice beyond applicable knowledge articles.
Set `confidence` to:
@@ -64,7 +76,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 web-services 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 web-services 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 API pages and web-service surfaces; 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 web-services 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 web-services 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 API pages, outbound HTTP integrations, and other web-service surfaces; 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: set `ODataKeyFields = SystemId`; add the three `*Allowed = false` guards to a read-only page; add the missing `OnOpenPage` isolation assignment). 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`.
@@ -74,7 +86,7 @@ Outcome selection:
- `completed` β the skill evaluated every worklist item; default when the skill finishes normally, including when the resulting `findings` array is empty.
- `no-knowledge` β no applicable web-services knowledge survived Source, Relevance, configuration filtering, and conflict resolution. `findings` is empty.
-- `not-applicable` β the task context contains no AL API surface, JavaScript webhook subscription lifecycle code, or JavaScript notification handler, or the `technologies` filter rejected the task.
+- `not-applicable` β the task context contains no AL API surface, outbound AL HTTP integration, 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.
diff --git a/schemas/findings-report.schema.json b/schemas/findings-report.schema.json
new file mode 100644
index 0000000..76712f4
--- /dev/null
+++ b/schemas/findings-report.schema.json
@@ -0,0 +1,159 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://github.com/microsoft/BCQuality/schemas/findings-report.schema.json",
+ "title": "BCQuality findings report",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["skill", "outcome", "summary", "findings", "suppressed"],
+ "properties": {
+ "skill": { "$ref": "#/definitions/skillReference" },
+ "outcome": {
+ "enum": ["completed", "not-applicable", "no-knowledge", "partial", "failed"]
+ },
+ "outcome-reason": { "type": "string", "minLength": 1 },
+ "summary": { "$ref": "#/definitions/summary" },
+ "findings": {
+ "type": "array",
+ "items": { "$ref": "#/definitions/finding" }
+ },
+ "suppressed": {
+ "type": "array",
+ "items": { "$ref": "#/definitions/suppressed" }
+ },
+ "sub-results": {
+ "type": "array",
+ "items": { "$ref": "#" }
+ },
+ "skipped-sub-skills": {
+ "type": "array",
+ "items": { "$ref": "#/definitions/skippedSubSkill" }
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "outcome": { "enum": ["partial", "failed"] }
+ }
+ },
+ "then": { "required": ["outcome-reason"] }
+ },
+ {
+ "if": {
+ "properties": {
+ "outcome": { "enum": ["not-applicable", "no-knowledge", "failed"] }
+ }
+ },
+ "then": {
+ "properties": {
+ "findings": { "maxItems": 0 }
+ }
+ }
+ }
+ ],
+ "definitions": {
+ "skillReference": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "version"],
+ "properties": {
+ "id": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
+ "version": { "type": "integer", "minimum": 1 }
+ }
+ },
+ "counts": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["blocker", "major", "minor", "info"],
+ "properties": {
+ "blocker": { "type": "integer", "minimum": 0 },
+ "major": { "type": "integer", "minimum": 0 },
+ "minor": { "type": "integer", "minimum": 0 },
+ "info": { "type": "integer", "minimum": 0 }
+ }
+ },
+ "coverage": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["worklist-size", "items-evaluated"],
+ "properties": {
+ "worklist-size": { "type": "integer", "minimum": 0 },
+ "items-evaluated": { "type": "integer", "minimum": 0 }
+ }
+ },
+ "summary": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["counts", "coverage"],
+ "properties": {
+ "counts": { "$ref": "#/definitions/counts" },
+ "coverage": { "$ref": "#/definitions/coverage" }
+ }
+ },
+ "reference": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["path"],
+ "properties": {
+ "path": { "type": "string", "minLength": 1, "pattern": "^[^\\\\]+$" },
+ "sha": { "type": "string", "pattern": "^[a-fA-F0-9]{40}$" }
+ }
+ },
+ "location": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["file", "line"],
+ "properties": {
+ "file": { "type": "string", "minLength": 1, "pattern": "^[^\\\\]+$" },
+ "line": { "type": "integer", "minimum": 1 },
+ "range": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["start-line", "end-line"],
+ "properties": {
+ "start-line": { "type": "integer", "minimum": 1 },
+ "end-line": { "type": "integer", "minimum": 1 }
+ }
+ }
+ }
+ },
+ "finding": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "severity", "message", "references", "confidence"],
+ "properties": {
+ "id": { "type": "string", "minLength": 1 },
+ "severity": { "enum": ["blocker", "major", "minor", "info"] },
+ "message": { "type": "string", "minLength": 1 },
+ "location": { "$ref": "#/definitions/location" },
+ "references": {
+ "type": "array",
+ "items": { "$ref": "#/definitions/reference" }
+ },
+ "confidence": { "enum": ["high", "medium", "low"] },
+ "from-sub-skill": { "type": "string", "minLength": 1 },
+ "domain": { "type": "string", "minLength": 1, "pattern": "^[^\\r\\n]+$" },
+ "suggested-code": { "type": "string", "minLength": 1 },
+ "suggested-code-omission-reason": { "type": "string", "minLength": 1 }
+ }
+ },
+ "suppressed": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["reference", "reason"],
+ "properties": {
+ "reference": { "$ref": "#/definitions/reference" },
+ "reason": { "enum": ["layer-precedence", "configuration"] }
+ }
+ },
+ "skippedSubSkill": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["skill", "reason"],
+ "properties": {
+ "skill": { "$ref": "#/definitions/skillReference" },
+ "reason": { "enum": ["configuration", "not-applicable"] }
+ }
+ }
+ }
+}
diff --git a/schemas/skill-index.schema.json b/schemas/skill-index.schema.json
new file mode 100644
index 0000000..01944bb
--- /dev/null
+++ b/schemas/skill-index.schema.json
@@ -0,0 +1,84 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://github.com/microsoft/BCQuality/schemas/skill-index.schema.json",
+ "title": "BCQuality action-skill index",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["version", "generatedAt", "skillCount", "sourceSnapshot", "skills"],
+ "properties": {
+ "version": { "const": 1 },
+ "generatedAt": { "type": "string", "format": "date-time" },
+ "skillCount": { "type": "integer", "minimum": 0 },
+ "sourceSnapshot": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
+ "skills": {
+ "type": "array",
+ "items": { "$ref": "#/definitions/skill" }
+ }
+ },
+ "definitions": {
+ "stringArray": {
+ "type": "array",
+ "items": { "type": "string", "minLength": 1 }
+ },
+ "skill": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "path",
+ "layer",
+ "id",
+ "version",
+ "title",
+ "description",
+ "inputs",
+ "outputs",
+ "filters",
+ "subSkills",
+ "sourceSha256"
+ ],
+ "properties": {
+ "path": { "type": "string", "pattern": "^(microsoft|community|custom)/skills/.+\\.md$" },
+ "layer": { "enum": ["microsoft", "community", "custom"] },
+ "id": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
+ "version": { "type": "integer", "minimum": 1 },
+ "title": { "type": "string", "minLength": 1 },
+ "description": { "type": "string", "minLength": 1 },
+ "inputs": { "$ref": "#/definitions/stringArray" },
+ "outputs": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 1,
+ "items": { "const": "findings-report" }
+ },
+ "filters": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["bc-version", "technologies", "countries", "application-area"],
+ "properties": {
+ "bc-version": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ { "type": "integer", "minimum": 1 },
+ { "type": "string", "pattern": "^(all|[1-9][0-9]*\\.\\.[1-9][0-9]*|[1-9][0-9]*\\.\\.)$" }
+ ]
+ }
+ },
+ "technologies": { "$ref": "#/definitions/stringArray" },
+ "countries": { "$ref": "#/definitions/stringArray" },
+ "application-area": { "$ref": "#/definitions/stringArray" }
+ }
+ },
+ "subSkills": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "pattern": "^(microsoft|community|custom)/skills/.+\\.md$"
+ }
+ },
+ "sourceSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
+ }
+ }
+ }
+}
diff --git a/skills/README.md b/skills/README.md
index 3d8fdfb..766131f 100644
--- a/skills/README.md
+++ b/skills/README.md
@@ -48,8 +48,6 @@ This gives the two skill formats distinct roles:
The host adapter and internal coordinator deliberately share the
`al-code-review` name because they represent the same user-facing operation in
their respective formats. Their locations distinguish their roles. The
-adapter remains distinct from BC-ALAgents' separately installed `al-review`
-skill, avoiding a collision in hosts that use one shared skill inventory. The
reference from the adapter to Entry, and from a dispatched super-skill to its
leaf skills, is intentional progressive disclosure. It avoids registering
every internal BCQuality protocol file as an ambient host skill while allowing
@@ -57,4 +55,4 @@ each review domain to run in an isolated context.
These contracts are stable. Changes require a PR approved by both maintainers.
-For the end-to-end flow β from orchestrator trigger through to findings integration β see [`../agent-consumption.md`](../agent-consumption.md). For the high-level project framing, see [`../README.md`](../README.md).
+For the end-to-end flow β from orchestrator trigger through to findings integration β see [How agents consume BCQuality](../docs/agent-consumption.md). For the high-level project framing, see [`../README.md`](../README.md).
diff --git a/skills/al-code-review/SKILL.md b/skills/al-code-review/SKILL.md
index 991a771..df54914 100644
--- a/skills/al-code-review/SKILL.md
+++ b/skills/al-code-review/SKILL.md
@@ -1,6 +1,6 @@
---
name: al-code-review
-description: Review Business Central AL code changes using BCQuality's curated rules. Use for an AL pull request, working-tree diff, branch, or individual AL file when BCQuality is installed as a standalone plugin.
+description: Review Business Central AL code using BCQuality's curated rules. Use for an AL app folder, pull request, working-tree diff, branch, or individual AL file when BCQuality is installed as a standalone plugin.
---
# AL code review
@@ -21,8 +21,11 @@ context and execute the resulting dispatch.
- Copy the caller's actual request verbatim into `goal`; do not replace a
focused request such as "review performance" with a generic full-review
goal.
- - Set `inputs-available` to the inputs actually available to the review,
- normally `pr-diff` for changes or `file-path` for one file.
+ - Set `inputs-available` to the inputs actually available to the review:
+ `folder-path` for an app or source folder, `pr-diff` for changes, or
+ `file-path` for one file. Pass the caller's actual path with the selected
+ input type; for a whole-app request in the current working directory, use
+ that directory as the `folder-path`.
- Set `technologies: [al]` when the input is known to be AL.
- Pass `bc-version`, `countries`, and `application-area` only when supplied
or reliably determined.
@@ -66,4 +69,3 @@ where a consumer prunes its checkout to policy before the agent runs and the
index is rebuilt over the pruned tree. Treat `BCQUALITY_ENABLED_LAYERS` as a
selection filter, never as a security boundary. A host that needs a genuine
deny mechanism must prune the installed tree itself.
-
diff --git a/skills/do.md b/skills/do.md
index a79c5fc..e5aef5b 100644
--- a/skills/do.md
+++ b/skills/do.md
@@ -19,7 +19,12 @@ An action skill is a single markdown file with YAML frontmatter. It lives inside
- `/community/skills/` β community-contributed action skills.
- `/custom/skills/` β partner or customer action skills (typically in a consumer repo, not in BCQuality itself).
-Action skills do not live at the repo root. The files in `/skills/` β the three meta-skill contracts (READ, DO, WRITE) and the entry-point skill (`entry.md`, `kind: entry-point`) β are the only skills that sit outside a layer. The entry-point skill structurally follows this same four-step pattern but produces a dispatch record rather than a findings-report; see `skills/entry.md` for its contract.
+Action skills do not live at the repo root. Layer-independent files in
+`/skills/` contain the three meta-skill contracts (READ, DO, WRITE), the
+entry-point skill (`entry.md`, `kind: entry-point`), and host-format adapters.
+Adapters are not action skills. Entry structurally follows the same
+four-step pattern but produces a dispatch record rather than a findings-report;
+see [entry.md](entry.md) for its contract.
## Skills hold mechanics; knowledge files hold BC facts
@@ -56,7 +61,24 @@ application-area: [all]
`bc-version`, `technologies`, `countries`, `application-area` are optional filters that let an orchestrator pre-select applicable skills for a task. They follow the same semantics as in READ.
-`inputs` is a list of abstract input types the skill **accepts**. Standard values: `pr-diff`, `object-list`, `file-path`, `repository`, `telemetry-query`. Semantics are any-of: the orchestrator supplies whichever listed input types it has, and the skill is invoked with a non-empty subset of its declared `inputs`. A skill that cannot proceed with the supplied subset MUST return `outcome: "not-applicable"`. `outputs` is always a single-element list naming the output kind; today only `findings-report` is defined.
+`inputs` is a list of abstract input types the skill **accepts**. Standard values:
+`pr-diff`, `object-list`, `file-path`, `folder-path`, `repository`, and
+`telemetry-query`. Semantics are any-of: the orchestrator supplies whichever
+listed input types it has, and the skill is invoked with a non-empty subset of
+its declared `inputs`. A skill that cannot proceed with the supplied subset
+MUST return `outcome: "not-applicable"`. `outputs` is always a single-element
+list naming the output kind; today only `findings-report` is defined.
+
+`file-path` is one file. `folder-path` is a directory whose recursively
+contained files form the complete current-state input, such as a Business
+Central app folder containing `app.json` and AL source. The input value is the
+actual path, not merely the name of the input type. The agent MUST enumerate
+the folder rather than reducing it to one representative file.
+
+Review skills use terms such as "diff", "changed files", and "changed code" as
+shorthand for the supplied review scope. For `folder-path`, every relevant file
+under the folder is in scope. A folder supplies no historical baseline:
+comparison-only rules MUST NOT infer a prior state that was not provided.
`sub-skills` is an optional field. When present and non-empty, the skill is a **super-skill** that composes other action skills; see *Composition* below. Values are repo-relative paths to action-skill files.
@@ -84,6 +106,12 @@ Every action skill MUST contain these five sections, in order:
Every action skill emits a single JSON document that conforms to this schema:
+The machine-readable structural schema is
+[`schemas/findings-report.schema.json`](../schemas/findings-report.schema.json).
+The rules below remain authoritative for semantic checks that JSON Schema
+cannot perform by itself, including summary arithmetic, reference existence,
+source-scope locations, and article-body retrieval.
+
```json
{
"skill": { "id": "string", "version": 1 },
@@ -137,6 +165,106 @@ The emitted document MUST be strict, valid JSON per [RFC 8259](https://www.rfc-e
AL source is the common failure case. Quoted identifiers (for example `Rec."No."`) and multi-line snippets routinely appear in `message`, `suggested-code`, and `suggested-code-omission-reason`, and each embedded quote or newline MUST be escaped when placed in a string value. A `suggested-code` payload that spans several lines is a single JSON string with `\n` separators, not a literal multi-line block. Emit the document as one JSON value with no trailing commentary, and do not rely on the consumer to repair unescaped output.
+### Consumer acceptance gate
+
+Capture the exact Task return as the immutable raw audit payload and primary
+transport. Preserve it unchanged in private run artifacts or host logs before
+creating any derived value. The accepted findings-report is either that exact
+return or the bounded normalized candidate described below; the raw audit
+payload never changes.
+
+Before the full acceptance gate, a coordinator MAY create a normalized
+candidate copy only through this deterministic procedure:
+
+1. Parse the exact return as strict JSON and provisionally check the complete
+ report without mutating it. Every acceptance rule below MUST already pass
+ except for one or more findings whose optional `location.range` has
+ `start-line != line`.
+2. Each such finding is eligible only when `location.line`,
+ `location.range.start-line`, and `location.range.end-line` are positive
+ integers, `start-line <= line <= end-line`, and the finding does not contain
+ the `suggested-code` field. Field presence disqualifies normalization even
+ if its value is empty because suggested code may be bound to the reported
+ range.
+3. Deep-copy the complete parsed report. In the candidate copy, remove only
+ `location.range` from every eligible finding. Retain `location.line` and
+ every other value unchanged. Do not add normalization metadata to the
+ findings-report.
+4. Record each removed range separately in private run telemetry or artifacts,
+ associated with the immutable raw audit payload. This record is
+ runner-owned and is not part of the declared report schema.
+5. Validate the entire normalized candidate with the existing full consumer
+ acceptance gate below. Only a candidate that passes every rule becomes the
+ accepted copy used for rollup. If any other validation defect exists, or
+ full validation fails, discard the candidate, preserve the raw payload, and
+ fail the complete leaf as before.
+
+This exception does not infer missing fields, alter references or paths, clamp
+line numbers, repair JSON, normalize a reversed or out-of-bounds range, remove
+a range from a finding containing `suggested-code`, or salvage arbitrary
+individual findings.
+
+Before accepting either the exact return or an eligible normalized candidate
+as a findings-report, a coordinator or host MUST validate it deterministically:
+
+1. Validate every required field, enum, type, conditional requirement, summary
+ count, coverage value, and leaf/super-skill constraint against this output
+ contract.
+2. For every knowledge-backed finding, verify each `references[].path` is an
+ exact repo-relative knowledge path that exists in the live BCQuality
+ snapshot, and verify `findings[].id` exactly equals
+ `references[0].path`. Verify each path is also present in the coordinator's
+ recorded set of complete article bodies retrieved for that leaf; catalog
+ membership alone is insufficient. Keep optional `references[].sha`
+ separate: it is commit provenance, not an article content hash.
+3. For every `location`, verify `file` is an exact source path in the supplied
+ review scope, the file exists in that source snapshot, and `line` and any
+ inclusive range identify existing lines with `start-line == line` and
+ `end-line >= start-line`.
+
+Hosts SHOULD execute `tools/Validate-FindingsReport.ps1` with the exact source
+scope and the leaf's recorded set of fully retrieved article paths. Pass
+`-SkillKind super -ExpectedCompositionPath ` when validating
+a super-skill's rolled-up report. Prepare that private artifact before leaf
+dispatch, after layer resolution and input compatibility checks. It contains
+`superSkill` (`id`, `version`), ordered selected `subSkills` (each with `id`,
+`version`), `skipped` (each with `id`, `version`, `reason`), and an initially
+empty `acceptedResults` array. Reasons are `configuration` or `not-applicable`;
+budget exhaustion is not a skip reason.
+Additional resolver metadata may be retained in the artifact, not the report.
+After each leaf passes its acceptance gate, the host saves the exact accepted
+copy in a private immutable file and appends an `acceptedResults` entry with
+`id`, `version`, and `reportPath`. Capture host-created failed validation
+results the same way. Paths may be absolute or relative to the composition
+artifact's directory. Capture the normalized accepted copy when normalization
+was permitted, not the invalid raw return. Do not expose these files or write
+access to the artifact to leaf workers or the composing model. Only the host
+may append captures; the pre-dispatch selection and exclusions remain fixed.
+The validator binds the super-skill and leaf identities and versions, checks
+selected order, and requires exact agreement on exclusions. Each nested leaf
+must exactly match its host-captured accepted JSON content, ignoring object
+property order but preserving array order, types, values, and field presence.
+Every captured leaf must be included; uncaptured or altered leaves are invalid.
+Every returned leaf must be unique; a selected leaf cannot be reclassified as skipped by the
+report. When selected leaves are missing, `outcome-reason` must name every
+missing ID exactly and top-level `from-sub-skill: "agent"` findings are forbidden.
+Without the artifact, validation remains structural and semantic but
+cannot prove composition completeness, selected versions, order, or legitimate
+exclusions, nor bind leaves to accepted host outputs. Duplicate or both
+returned-and-skipped leaf IDs are invalid even without the artifact. Never
+derive the expected composition from model output.
+Pass
+`-AllowBoundedNormalization` only when the host preserves the immutable raw
+payload and records `removedRanges` in private telemetry as required above.
+
+Validation failure invalidates the complete return; consumers MUST NOT salvage
+individual findings, infer missing fields, reconstruct JSON, clamp ranges,
+rewrite paths, or otherwise silently repair model output. Preserve the invalid
+raw payload unchanged. Record a separate failed validation result for that leaf
+with no findings, and derive the super-skill outcome as `partial` or `failed`
+using the normal rollup rules. Worker-side report-file persistence is optional
+and never replaces validation of the accepted exact or normalized copy.
+
### Field semantics
**`outcome`** (required) β
@@ -231,7 +359,7 @@ Omit `suggested-code` only when the appropriate fix depends on context the skill
- `reference` β the suppressed file (same object shape as `findings[].references`).
- `reason` β `layer-precedence` when another layer won under READ's precedence rules; `configuration` when the consumer disabled the file's layer.
-**`sub-results`** β super-skills only. Array of complete findings-reports, one per sub-skill that was invoked (i.e., every sub-skill not listed in `skipped-sub-skills`). Each entry MUST itself conform to this output contract. Leaf skills MUST NOT emit `sub-results`.
+**`sub-results`** β super-skills only. Array of complete findings-reports, one per invoked sub-skill, with no duplicate skill IDs. Each entry MUST itself conform to this output contract. Entries MUST appear in the worklist's declared order, regardless of invocation or completion order. A selected leaf left uninvoked by budget exhaustion has no fabricated sub-result and is not a configured or input-incompatible skip; its absence requires the incomplete-composition outcome below. Leaf skills MUST NOT emit `sub-results`.
**`skipped-sub-skills`** β super-skills only. Array of sub-skills that were declared in frontmatter but not invoked. `reason` is `configuration` when the orchestrator disabled the sub-skill, or `not-applicable` when the super-skill's Relevance step ruled it out.
@@ -248,11 +376,46 @@ A **super-skill** is an action skill whose frontmatter declares a non-empty `sub
Composition is flat: a super-skill MAY list only leaf skills (skills without their own `sub-skills`). Nested super-skills are not permitted in v1.
+### Scheduling boundary
+
+The super-skill defines which leaves must run, the input and output contracts,
+and how their results are composed. It does not prescribe a model, concurrency
+limit, retry policy, or telemetry system. Those choices belong to the
+orchestrator.
+
+Each leaf invocation MUST remain a discrete evaluation with its own complete
+findings-report. An orchestrator MAY execute independent leaves serially or
+concurrently, but MUST attempt every worklisted leaf, preserve `sub-results`
+in the declared worklist order, and wait for every started invocation to
+finish before final rollup. If its execution budget prevents dispatching
+remaining leaves, preserve the unfinished selection in the host-owned expected
+composition and use the incomplete-composition outcome below. Do not perform
+the super-skill self-review until every selected leaf has returned. Scheduling
+MUST NOT change relevance, coverage, failure, reference-integrity, or output
+semantics.
+
+Orchestrators SHOULD generate `skill-index.json` with
+`tools/Build-SkillIndex.ps1` instead of parsing Markdown. Each declared
+`subSkills` path defines an ordered leaf slot: its indexed `id` identifies the
+slot, while its path fixes the declaration order. Before scheduling leaves,
+the orchestrator MUST resolve each slot to the highest-precedence enabled,
+non-disabled leaf with that `id` (`custom` over `community` over `microsoft`).
+If no implementation remains, the slot is skipped with `reason:
+"configuration"`. The orchestrator SHOULD use
+`tools/Resolve-SkillWorklist.ps1` for this resolution. Action-skill
+frontmatter remains the source of truth; the generated index conforms to
+`schemas/skill-index.schema.json`.
+
+Layer resolution MUST NOT reorder slots. Multiple implementations with the
+same `id` are valid only when they belong to different layers; duplicate IDs
+within one layer are invalid. Disabling a winning implementation falls back
+to the next enabled implementation for that slot when one exists.
+
### Section interpretation for super-skills
The five required sections still apply. Their meaning shifts from knowledge files to sub-skills:
-- `## Source` β names the sub-skills invoked (mirrors `sub-skills` in frontmatter).
+- `## Source` β names the declared sub-skill slots (mirrors `sub-skills` in frontmatter) and resolves their effective leaf implementations by the layered rule above.
- `## 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. 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).
@@ -260,7 +423,16 @@ The five required sections still apply. Their meaning shifts from knowledge file
### Outcome rollup
-A super-skill's `outcome` is derived from its sub-skills' outcomes. Let S be the multiset of sub-skill outcomes for sub-skills in the worklist (skipped sub-skills do not contribute):
+A super-skill's `outcome` is derived from its selected worklist and returned
+sub-skills' outcomes. If selected leaves have no returned report, the outcome
+is `partial` when at least one returned report is non-`failed`, or `failed`
+when no non-`failed` report is available. It MUST NOT be `completed`,
+`not-applicable`, or `no-knowledge`. Name unfinished leaf IDs in
+`outcome-reason`, preserve the valid returned reports, and never invent
+successful or failed invocations for leaves that were not invoked.
+
+When every selected leaf has returned, let S be the multiset of their outcomes
+(skipped sub-skills do not contribute):
- `failed` β every element of S is `failed`.
- `partial` β S contains at least one `partial`, OR S contains at least one `failed` alongside at least one non-`failed` outcome.
@@ -274,7 +446,13 @@ When the worklist is empty (every sub-skill was skipped), `outcome` is `not-appl
### Rolled-up summary
-`summary.counts` is the sum of sub-skill counts. `summary.coverage.worklist-size` and `items-evaluated` are the sums across invoked sub-skills.
+`summary.counts` counts the findings in the super-skill's final top-level
+`findings[]`, after failed sub-results have been excluded and duplicates have
+been merged. It MUST NOT be calculated by summing sub-skill counts, because the
+same concern may appear in more than one sub-result.
+
+`summary.coverage.worklist-size` and `items-evaluated` are the sums across
+invoked sub-skills whose outcomes are not `failed`.
### Suppression scope
@@ -282,15 +460,16 @@ A super-skill's top-level `suppressed[]` remains knowledge-file-only and is typi
## Worked example
-A minimal action skill that cites applicable guidance for a changed AL file, without generating findings of its own:
+A minimal action skill that reviews a changed AL file against applicable
+guidance. Relevance alone never produces a finding:
```yaml
---
kind: action-skill
-id: cite-applicable-guidance
+id: review-applicable-guidance
version: 1
-title: Cite applicable guidance
-description: Lists knowledge files relevant to a changed AL file.
+title: Review applicable guidance
+description: Reviews a changed AL file against applicable knowledge.
inputs: [file-path]
outputs: [findings-report]
technologies: [al]
@@ -308,7 +487,12 @@ Filter by `technologies: [al]` and `bc-version` matching the target environment.
Intersect `keywords` with tokens derived from the target file's object name and changed members.
## Action
-For each worklist entry, emit one finding with severity `info`, a message naming the concern, and a reference object pointing to the knowledge file.
+Read each worklisted article in full and compare its normative guidance to the
+input. Emit a finding only for a concrete violation or an observation the
+article explicitly defines, with justified severity, evidence, and a reference
+copied from the discovered article path. Do not report an article merely
+because it was relevant. If every item was evaluated and none warrants a
+finding, return `completed` with an empty `findings` array.
## Output
Conforms to the DO output contract.
diff --git a/skills/entry.md b/skills/entry.md
index 0196c35..efa84a1 100644
--- a/skills/entry.md
+++ b/skills/entry.md
@@ -23,6 +23,7 @@ task-context:
inputs-available: # values the orchestrator has ready to pass to a chosen skill
- pr-diff
- file-path
+ - folder-path
technologies: [al]
bc-version: 28
countries: [w1]
diff --git a/skills/read.md b/skills/read.md
index 6a2080d..dddd7db 100644
--- a/skills/read.md
+++ b/skills/read.md
@@ -7,7 +7,9 @@ title: Schema + Use β how to read a knowledge file
# READ
-Every consumer of BCQuality β an agent, an action skill, a human reviewer β reads this file first. It defines what a knowledge file is, what fields it contains, what they mean, and how to reconcile multiple files.
+Read this contract before interpreting knowledge files. Task execution starts
+at [Entry](entry.md); READ is loaded on demand when a dispatched skill needs
+it. It defines knowledge fields, their meaning, and how to reconcile files.
This contract is stable. Changes require a PR approved by both maintainers.
@@ -131,7 +133,7 @@ Rules:
- A sample file is identified by the article's slug followed by a `..` suffix. The supported kinds are `good` and `bad`. Additional kinds MAY be introduced by a layer; consumers MUST ignore unknown kinds without failing.
- The extension matches the technology (`al`, `ps1`, `js`, `kql`, β¦). A single article MAY carry samples in multiple technologies if the article's frontmatter `technologies` lists them.
-- Articles MAY have a `good` sample only, a `bad` sample only, both, or neither. The article text SHOULD reference each sample it ships, using a relative path like `` `.good.al` ``.
+- Articles MAY have a `good` sample only, a `bad` sample only, both, or neither. The article text SHOULD reference each sample it ships with a relative Markdown link whose label retains the backticked filename, like `` [`.good.al`](.good.al) ``.
- Samples are **demonstration-only**. They are not deployed, not compiled as part of a published app, and not derived from the Business Central base application source. Each sample is self-contained and exists purely to make the accompanying article concrete for humans and agents.
- Layer precedence applies to sample files the same way it applies to articles: a `/custom/knowledge//.good.al` overrides a `/microsoft/knowledge//.good.al` for the same article in the same layer hierarchy.
@@ -147,3 +149,58 @@ The standard workflow for finding applicable files:
4. Resolve conflicts via layer precedence.
Steps 1β3 are deterministic; step 4 is applied only when conflicts are detected.
+
+### Bounded retrieval for review skills
+
+Resolve `$root` to the BCQuality root, not the reviewed source. Entry prepares
+the index once before dispatch; that prepared index is the catalog snapshot and
+leaves use it read-only. Catalog retrieval validates the complete index metadata
+and returned paths without reopening or rehashing article bodies. Post-Entry
+body changes therefore take effect only after Entry rebuilds the index; exact
+body retrieval rejects a selected article whose content hash differs from its
+prepared row. In one PowerShell tool session, invoke the helpers with `&` so
+array arguments remain arrays:
+
+```powershell
+& (Join-Path $root 'tools\Search-Knowledge.ps1') -Domain $domain -Technologies @('al')
+& (Join-Path $root 'tools\Get-KnowledgeArticles.ps1') -Paths @($exactPath)
+```
+
+Pass enabled layers and only task dimensions that are actually known. Catalog
+retrieval returns every domain and READ-applicable row: it does not rank,
+sample, apply top-k, deduplicate by basename, or omit rows based on query text.
+Consume every page by passing `continuation.offset` as `-Offset` and
+`continuation.snapshot` as `-Snapshot` with the unchanged request until
+`complete` is `true`. Each page repeats request context, defaults, and totals.
+An omitted applicability field on a row inherits that page's `defaults`; it
+does not mean unknown task context. Preserve every row's exact `path`, `layer`,
+complete `keywords`, `title`, one-line `description`, non-default applicability
+fields, explicit `applicability`, and `unknownDimensions`.
+
+Apply the leaf's existing Relevance and Worklist to the complete catalog union.
+Split the resulting exact paths into stable chunks of at most eight; never pass
+more paths than `-MaxArticles` (whose maximum is eight). Request article bodies
+only by one such chunk. Consume every
+returned `body`, then request `remainingPaths` with
+`continuation.snapshot` as `-Snapshot` until `complete` is `true`, preserving
+the other request settings. Continuation is confined to that chunk. Bodies are
+original strict UTF-8 text with source byte counts and SHA-256 content hashes;
+they are never summarized or truncated. Samples are not loaded unless
+requested explicitly with `-Samples` and exact sibling paths; their sibling
+article must match its prepared hash and contain the exact READ link.
+
+The default serialized response limit is 16,000 bytes including its output
+newline. Never combine pages or bodies into an unbounded prompt. A malformed or
+internally inconsistent prepared index, changed continuation snapshot, selected
+article hash mismatch, invalid continuation, unsafe or missing path, invalid
+UTF-8, broken sample link, oversized path chunk, or row/envelope that cannot fit
+fails explicitly. Entry is the only index preparation point: a leaf does not
+rebuild. If PowerShell, a helper, or a valid prepared index is unavailable,
+discover exact paths across the enabled domain folders and use native bounded
+reads through EOF, validating frontmatter per READ and never treating retrieval
+failure as an empty result.
+
+The helpers' `sha256` and `bytes` fields describe the retrieved file content.
+They are not citation provenance. Optional findings `references[].sha` is the
+BCQuality commit SHA the skill reviewed; omit it when that provenance is not
+available or would misrepresent uncommitted content.
diff --git a/skills/write.md b/skills/write.md
index 8096f1a..c2edab5 100644
--- a/skills/write.md
+++ b/skills/write.md
@@ -16,7 +16,7 @@ Before authoring anything, confirm a knowledge file is the right artifact. BCQua
- **Skills** (`*/skills/**`) hold only finder/applier mechanics β how to discover, filter, worklist, and emit findings. See `skills/do.md`.
- **Knowledge files** (`*/knowledge/**`) hold every Business-Central-specific fact a skill acts on.
-A new BC fact is therefore a knowledge file, never a skill edit. In particular, if you arrived here because a review agent flagged something it should not have (a false positive) or missed something it should have caught, the remedy is a knowledge file β apply the admission test in the [README](../README.md#what-belongs-here): *would a capable LLM get this wrong without the file?* If you find yourself editing a skill to stop it flagging something, stop and write a knowledge file instead.
+A new BC fact is therefore a knowledge file, never a skill edit. In particular, if you arrived here because a review agent flagged something it should not have (a false positive) or missed something it should have caught, the remedy is a knowledge file β apply the [admission test](../docs/contributing.md#what-belongs-here): *would a capable LLM get this wrong without the file?* If you find yourself editing a skill to stop it flagging something, stop and write a knowledge file instead.
### Negative knowledge is first-class
@@ -56,6 +56,13 @@ Target under 100 lines. Ideal under 50. Long files almost always mean two concer
Custom `##` sections are permitted when they serve the concern (for example, `## Applies to` for scope caveats or `## See also` for related files). Consumers are not required to understand them, so do not put load-bearing content there.
+When adding or changing a platform claim, cite an authoritative public source
+where available. A short `## References` section can link the relevant API,
+property documentation, or public source definition. If no such source is
+available, identify the evidence or policy basis explicitly; do not imply an
+official guarantee. Keep the actual rule and its exceptions in normative
+sections, not only in references. See [sources and examples](../docs/contributing.md#sources-and-examples).
+
## No fenced code blocks
Knowledge files do not contain code. Samples live as **sibling files** next to the article β `.good.al`, `.bad.al`, etc. β in the same knowledge-layer folder. See `skills/read.md` for the full convention. This keeps knowledge files retrieval-friendly and prevents code from drifting out of sync with BC platform changes buried inside prose.
@@ -93,7 +100,7 @@ The `/custom/` layer is **empty by default** in the upstream `microsoft/BCQualit
Before authoring or scaffolding any file under `/custom/knowledge/` or `/custom/skills/`, an author β human or agent β MUST confirm the working repository is **not** `microsoft/BCQuality`:
- Check the `origin` remote: `git remote get-url origin`. If it points at `github.com/microsoft/BCQuality`, stop β you are in the upstream repo, not a fork.
-- If you are in the upstream repo, do not write the file. Either fork the repository (or clone it into your organization's own repo) and add the custom content there, or β if the guidance is genuinely shareable β author it in `/community/knowledge/` instead.
+- If you are in the upstream repo, do not write the custom file. Either fork the repository (or clone it into your organization's own repo) and add the custom content there, or β if the guidance is genuinely shareable β use the shared layer that owns the domain, following *Choosing a layer* above. Community is not a staging area for Microsoft-owned domains.
A pull request that adds `/custom/` content to `microsoft/BCQuality` will be **automatically closed** by the `Guard custom layer` workflow. Validate the fork precondition first so authoring effort is not wasted on a PR that cannot be merged.
@@ -109,7 +116,8 @@ Before opening a pull request:
- Frontmatter `domain` exactly matches the containing domain folder.
- File is in the correct layer and domain folder.
- Name is kebab-case and descriptive.
-- Every companion sample is referenced by filename from the article, and every referenced sample exists.
+- Every companion sample has a clickable relative link retaining its backticked filename, and every referenced sample exists.
+- Platform claims link supporting sources where available; policy or empirical guidance is identified as such.
- Every review-leaf domain has at least one article with both `.good.al` and `.bad.al` companions; the evaluation harness derives positive and clean controls from that convention automatically.
Agents scaffolding new files SHOULD run this checklist programmatically before emitting the file.
diff --git a/tools/Bounded-Results.ps1 b/tools/Bounded-Results.ps1
new file mode 100644
index 0000000..6def944
--- /dev/null
+++ b/tools/Bounded-Results.ps1
@@ -0,0 +1,117 @@
+# Shared deterministic paging. Callers build the complete immutable result first.
+#requires -Version 7.2
+Set-StrictMode -Version Latest
+
+function Get-ResultSnapshot {
+ param([Parameter(Mandatory)] $Value)
+
+ $json = ConvertTo-Json -InputObject $Value -Depth 30 -Compress
+ return [Convert]::ToHexString(
+ [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($json))
+ ).ToLowerInvariant()
+}
+
+function Get-SerializedByteCount {
+ param([Parameter(Mandatory)] [string] $Json)
+
+ # PowerShell writes one platform newline after the returned JSON string.
+ return [Text.Encoding]::UTF8.GetByteCount($Json) +
+ [Text.Encoding]::UTF8.GetByteCount([Environment]::NewLine)
+}
+
+function ConvertTo-BoundedPage {
+ param(
+ [Parameter(Mandatory)] [Collections.IDictionary] $Header,
+ [Parameter(Mandatory)] [Collections.IDictionary] $Groups,
+ [ValidateRange(0, 2147483647)] [int] $Offset = 0,
+ [string] $Snapshot,
+ [ValidateRange(1024, 16000)] [int] $MaxBytes = 16000
+ )
+
+ $total = 0
+ foreach ($name in $Groups.Keys) {
+ $total += $Groups[$name].Count
+ }
+ if (($total -eq 0 -and $Offset -ne 0) -or ($total -gt 0 -and $Offset -ge $total)) {
+ throw "Invalid Offset=$Offset for totalCount=$total; no rows were returned."
+ }
+ if ($Offset -gt 0 -and -not $Snapshot) {
+ throw 'Continuation requires Snapshot from the preceding page.'
+ }
+ if ($Snapshot -and $Snapshot -cne $Header.snapshot) {
+ throw 'Snapshot changed or continuation belongs to another request. Discard partial results and restart at Offset=0.'
+ }
+
+ $page = [ordered]@{}
+ foreach ($key in $Header.Keys) {
+ $page[$key] = $Header[$key]
+ }
+ $page.offset = $Offset
+ $page.returnedCount = 0
+ $page.totalCount = $total
+ $page.remainingCount = $total - $Offset
+ $page.complete = ($total -eq 0)
+ $page.continuation = if ($total) {
+ [ordered]@{ offset = $Offset; snapshot = $Header.snapshot }
+ }
+ else {
+ $null
+ }
+ foreach ($name in $Groups.Keys) {
+ $page[$name] = [Collections.Generic.List[object]]::new()
+ }
+
+ $json = ConvertTo-Json -InputObject $page -Depth 30 -Compress
+ if ((Get-SerializedByteCount -Json $json) -gt $MaxBytes) {
+ throw "Page envelope exceeds MaxBytes=$MaxBytes. Use READ's path-discovery fallback; never truncate."
+ }
+
+ $position = 0
+ foreach ($name in $Groups.Keys) {
+ foreach ($row in $Groups[$name]) {
+ if ($position++ -lt $Offset) {
+ continue
+ }
+
+ $page[$name].Add($row)
+ $page.returnedCount++
+ $page.remainingCount--
+ $page.complete = ($page.remainingCount -eq 0)
+ $page.continuation = if ($page.complete) {
+ $null
+ }
+ else {
+ [ordered]@{
+ offset = $Offset + $page.returnedCount
+ snapshot = $Header.snapshot
+ }
+ }
+
+ $next = ConvertTo-Json -InputObject $page -Depth 30 -Compress
+ if ((Get-SerializedByteCount -Json $next) -gt $MaxBytes) {
+ $page[$name].RemoveAt($page[$name].Count - 1)
+ $page.returnedCount--
+ $page.remainingCount++
+ $page.complete = $false
+ $page.continuation = [ordered]@{
+ offset = $Offset + $page.returnedCount
+ snapshot = $Header.snapshot
+ }
+ if ($page.returnedCount -eq 0) {
+ $rowPath = $null
+ if ($row -is [Collections.IDictionary]) {
+ if ($row.Contains('path')) { $rowPath = $row['path'] }
+ }
+ elseif ($null -ne $row -and $row.PSObject.Properties['path']) {
+ $rowPath = $row.PSObject.Properties['path'].Value
+ }
+ $identity = if ($rowPath) { " at $rowPath" } else { " at Offset=$Offset" }
+ throw "One complete $name row plus envelope exceeds MaxBytes=$MaxBytes$identity. No row was clipped."
+ }
+ return $json
+ }
+ $json = $next
+ }
+ }
+ return $json
+}
diff --git a/tools/Build-KnowledgeIndex.ps1 b/tools/Build-KnowledgeIndex.ps1
index 5835e5d..d246ff3 100644
--- a/tools/Build-KnowledgeIndex.ps1
+++ b/tools/Build-KnowledgeIndex.ps1
@@ -30,6 +30,8 @@
expected to prune its clone to policy first). For provenance and to
reproduce a consumer's exact view, pass -EnabledLayers to restrict the walk
to those layers and to record the policy in the index header.
+ Invalid articles are omitted with a path-specific warning so one bad
+ optional layer article cannot block valid siblings.
.PARAMETER BCQualityRoot
Path to the BCQuality content root to index (typically a filtered clone).
@@ -69,6 +71,17 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
+. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1')
+
+if ($PSBoundParameters.ContainsKey('EnabledLayers')) {
+ if ($null -eq $EnabledLayers) {
+ throw 'EnabledLayers must be an array; omit it to index all layers.'
+ }
+ if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or
+ @($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) {
+ throw 'EnabledLayers must contain unique canonical lowercase layer names.'
+ }
+}
# Default to the clone root (parent of this script's tools/ folder) so the
# agent's Entry preparation step can invoke this with no arguments from the
@@ -93,6 +106,45 @@ function Get-RelativePath {
return ($rel -replace '\\', '/')
}
+function Get-BytesSha256 {
+ param([byte[]] $Bytes)
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try {
+ return ([BitConverter]::ToString($sha.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant()
+ }
+ finally {
+ $sha.Dispose()
+ }
+}
+
+function Read-ArticleSource {
+ param([string] $Path)
+ $bytes = [IO.File]::ReadAllBytes($Path)
+ try {
+ $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes)
+ }
+ catch [Text.DecoderFallbackException] {
+ throw [IO.InvalidDataException]::new('invalid UTF-8', $_.Exception)
+ }
+ return [pscustomobject]@{
+ bytes = $bytes
+ text = $text
+ sha256 = Get-BytesSha256 -Bytes $bytes
+ }
+}
+
+function Get-ValueSha256 {
+ param([Parameter(Mandatory)] $Value)
+ $bytes = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject $Value -Depth 8 -Compress))
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try {
+ return ([BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-', '').ToLowerInvariant()
+ }
+ finally {
+ $sha.Dispose()
+ }
+}
+
# Trims a Description to a single short line (<= $Max chars) for the lean
# index. Takes the first sentence; truncates on a word boundary if still long.
function Get-LeanDescription {
@@ -116,9 +168,12 @@ function ConvertFrom-ArticleFrontmatter {
# Pattern) is included; the index is a lossless substitute for the
# frontmatter + Description the worklist predicate reads, not a
# substitute for the article's normative guidance.
- param([string] $Path)
+ param(
+ [string] $Path,
+ [string] $Text
+ )
- $lines = Get-Content -LiteralPath $Path -ErrorAction Stop
+ $lines = [regex]::Split($Text.TrimStart([char]0xfeff), '\r\n|\n|\r')
# Frontmatter is the first '---'-delimited block.
if ($lines.Count -lt 1 -or $lines[0].Trim() -ne '---') { return $null }
@@ -129,19 +184,42 @@ function ConvertFrom-ArticleFrontmatter {
if ($fmEnd -lt 0) { return $null }
$fm = @{}
+ $arrayFields = @('bc-version', 'keywords', 'technologies', 'countries', 'application-area')
for ($i = 1; $i -lt $fmEnd; $i++) {
$line = $lines[$i]
if ($line -match '^\s*([a-zA-Z][\w-]*)\s*:\s*(.*)$') {
$key = $Matches[1]
$val = $Matches[2].Trim()
- if ($val -match '^\[(.*)\]$') {
+ if ($key -in $arrayFields) {
+ if ($val -notmatch '^\[(.*)\]$') {
+ throw [IO.InvalidDataException]::new(
+ "frontmatter field '$key' must use non-empty bracket-array syntax"
+ )
+ }
$inner = $Matches[1].Trim()
- if ($inner -eq '') { $fm[$key] = @() }
- else { $fm[$key] = @($inner -split '\s*,\s*' | ForEach-Object { $_.Trim() }) }
+ if ($inner -eq '') {
+ throw [IO.InvalidDataException]::new(
+ "frontmatter field '$key' must use non-empty bracket-array syntax"
+ )
+ }
+ $values = @($inner -split '\s*,\s*' | ForEach-Object { $_.Trim() })
+ if (@($values | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count) {
+ throw [IO.InvalidDataException]::new(
+ "frontmatter field '$key' must use non-empty bracket-array syntax"
+ )
+ }
+ $fm[$key] = $values
}
elseif ($val -ne '') { $fm[$key] = $val }
}
}
+ foreach ($field in $arrayFields) {
+ if (-not $fm.ContainsKey($field) -or $fm[$field] -isnot [array] -or -not $fm[$field].Count) {
+ throw [IO.InvalidDataException]::new(
+ "frontmatter field '$field' must use non-empty bracket-array syntax"
+ )
+ }
+ }
# Body parsing: H1 title and the full Description section. The Description
# is the article's primary retrieval target per READ and is captured
@@ -185,42 +263,71 @@ $indexArticles = [System.Collections.Generic.List[object]]::new()
foreach ($layerDir in @('microsoft', 'community', 'custom')) {
$kbRoot = Join-Path $BCQualityRoot (Join-Path $layerDir 'knowledge')
if (-not (Test-Path $kbRoot)) { continue }
- if ($EnabledLayers -and ($EnabledLayers -notcontains $layerDir)) { continue }
+ if ($EnabledLayers -and ($EnabledLayers -cnotcontains $layerDir)) { continue }
- Get-ChildItem -LiteralPath $kbRoot -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue |
- Sort-Object FullName |
- ForEach-Object {
- $rel = Get-RelativePath -Root $BCQualityRoot -Full $_.FullName
- $parsed = $null
- try { $parsed = ConvertFrom-ArticleFrontmatter -Path $_.FullName } catch { $parsed = $null }
+ $files = @(
+ Get-ChildItem -LiteralPath $kbRoot -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue |
+ Sort-Object FullName
+ )
+ foreach ($file in $files) {
+ $rel = Get-RelativePath -Root $BCQualityRoot -Full $file.FullName
+ try {
+ $source = Read-ArticleSource -Path $file.FullName
+ $parsed = ConvertFrom-ArticleFrontmatter -Path $file.FullName -Text $source.text
if (-not $parsed) {
- # Invalid/unparseable file: list path + domain-from-path so it
- # is never silently dropped from discovery. Consumers fall back
- # to reading it in full.
- $domainFromPath = if ($rel -match '/knowledge/([^/]+)/') { $Matches[1] } else { '' }
- $indexArticles.Add([pscustomobject]@{
- path = $rel; layer = $layerDir; domain = $domainFromPath
- 'bc-version' = @(); technologies = @(); countries = @(); 'application-area' = @()
- keywords = @(); title = ''; description = ''; parsed = $false
- }) | Out-Null
- return
+ throw [IO.InvalidDataException]::new('missing or unterminated frontmatter')
+ }
+ foreach ($required in @(
+ @('domain', $parsed.domain),
+ @('H1 title', $parsed.title),
+ @('Description', $parsed.description)
+ )) {
+ if ([string]::IsNullOrWhiteSpace([string]$required[1])) {
+ throw [IO.InvalidDataException]::new("missing $($required[0])")
+ }
}
- $indexArticles.Add([pscustomobject]@{
- path = $rel
- layer = $layerDir
- domain = $parsed.domain
- 'bc-version' = @($parsed.'bc-version')
- technologies = @($parsed.technologies)
- countries = @($parsed.countries)
- 'application-area' = @($parsed.'application-area')
- keywords = @($parsed.keywords)
- title = $parsed.title
- description = if ($FullIndex) { $parsed.description } else { Get-LeanDescription -Text $parsed.description }
- parsed = $true
- }) | Out-Null
}
+ catch [IO.InvalidDataException] {
+ Write-Warning "Skipping invalid knowledge article '$rel': $($_.Exception.Message)."
+ continue
+ }
+
+ $article = [ordered]@{
+ path = $rel
+ layer = $layerDir
+ domain = $parsed.domain
+ 'bc-version' = @($parsed.'bc-version')
+ technologies = @($parsed.technologies)
+ countries = @($parsed.countries)
+ 'application-area' = @($parsed.'application-area')
+ keywords = @($parsed.keywords)
+ title = $parsed.title
+ description = if ($FullIndex) { $parsed.description } else { Get-LeanDescription -Text $parsed.description }
+ parsed = $true
+ sourceSha256 = $source.sha256
+ }
+ $problem = Get-KnowledgeMetadataProblem -Row $article
+ if ($problem) {
+ Write-Warning "Skipping invalid knowledge article '$rel': $problem."
+ continue
+ }
+ $indexArticles.Add($article) | Out-Null
+ }
}
+$articlesByPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
+foreach ($article in $indexArticles) {
+ if (-not $articlesByPath.TryAdd($article.path, $article)) {
+ throw "Duplicate knowledge path while building source snapshot: $($article.path)"
+ }
+}
+$sourcePaths = [string[]]@($articlesByPath.Keys)
+[Array]::Sort($sourcePaths, [StringComparer]::Ordinal)
+$sourceManifest = @(
+ foreach ($path in $sourcePaths) {
+ [ordered]@{ path = $path; sha256 = $articlesByPath[$path].sourceSha256 }
+ }
+)
$index = [pscustomobject]@{
version = 1
generatedAt = (Get-Date).ToUniversalTime().ToString('o')
@@ -228,6 +335,7 @@ $index = [pscustomobject]@{
knowledgeAllow= @($KnowledgeAllow)
knowledgeDeny = @($KnowledgeDeny)
articleCount = $indexArticles.Count
+ sourceSnapshot= Get-ValueSha256 -Value $sourceManifest
articles = @($indexArticles)
}
diff --git a/tools/Build-SkillIndex.ps1 b/tools/Build-SkillIndex.ps1
new file mode 100644
index 0000000..d4028c2
--- /dev/null
+++ b/tools/Build-SkillIndex.ps1
@@ -0,0 +1,257 @@
+<#
+.SYNOPSIS
+ Builds the machine-readable BCQuality action-skill index.
+
+.DESCRIPTION
+ Action-skill frontmatter remains the source of truth. This script emits the
+ versioned JSON contract orchestrators consume so they do not need to parse
+ Markdown or duplicate composition rules.
+
+.PARAMETER BCQualityRoot
+ BCQuality repository or filtered content root.
+
+.PARAMETER IndexPath
+ Output path. Defaults to /skill-index.json.
+
+.OUTPUTS
+ Returns the number of indexed action skills.
+#>
+[CmdletBinding()]
+param(
+ [string] $BCQualityRoot,
+ [string] $IndexPath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+if (-not $BCQualityRoot) {
+ $BCQualityRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+}
+if (-not (Test-Path -LiteralPath $BCQualityRoot -PathType Container)) {
+ throw "BCQuality root not found: $BCQualityRoot"
+}
+$BCQualityRoot = (Resolve-Path -LiteralPath $BCQualityRoot).Path
+if (-not $IndexPath) {
+ $IndexPath = Join-Path $BCQualityRoot 'skill-index.json'
+}
+
+function Get-RelativePath {
+ param([string] $Root, [string] $Full)
+
+ return ($Full.Substring($Root.Length).TrimStart([char]'/', [char]'\') -replace '\\', '/')
+}
+
+function Get-Sha256 {
+ param([byte[]] $Bytes)
+
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try {
+ return ([BitConverter]::ToString($sha.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant()
+ }
+ finally {
+ $sha.Dispose()
+ }
+}
+
+function Get-ValueSha256 {
+ param([Parameter(Mandatory)] $Value)
+
+ return Get-Sha256 -Bytes ([Text.Encoding]::UTF8.GetBytes(
+ (ConvertTo-Json -InputObject $Value -Depth 12 -Compress)
+ ))
+}
+
+function ConvertFrom-SkillFrontmatter {
+ param(
+ [string] $Path,
+ [string] $Text
+ )
+
+ $lines = [regex]::Split($Text.TrimStart([char]0xfeff), '\r\n|\n|\r')
+ if ($lines.Count -lt 3 -or $lines[0].Trim() -ne '---') {
+ throw [IO.InvalidDataException]::new("Missing frontmatter in '$Path'.")
+ }
+
+ $end = -1
+ for ($i = 1; $i -lt $lines.Count; $i++) {
+ if ($lines[$i].Trim() -eq '---') {
+ $end = $i
+ break
+ }
+ }
+ if ($end -lt 0) {
+ throw [IO.InvalidDataException]::new("Unterminated frontmatter in '$Path'.")
+ }
+
+ $frontmatter = [ordered]@{}
+ for ($i = 1; $i -lt $end; $i++) {
+ $line = $lines[$i]
+ if ($line -notmatch '^([a-zA-Z][\w-]*)\s*:\s*(.*)$') {
+ continue
+ }
+
+ $key = $Matches[1]
+ $value = $Matches[2].Trim()
+ if ($value -eq '') {
+ $items = [System.Collections.Generic.List[string]]::new()
+ while ($i + 1 -lt $end -and $lines[$i + 1] -match '^\s+-\s+(.+?)\s*$') {
+ $i++
+ $items.Add($Matches[1].Trim().Trim('"', "'")) | Out-Null
+ }
+ $frontmatter[$key] = @($items)
+ continue
+ }
+
+ if ($value -match '^\[(.*)\]$') {
+ $inner = $Matches[1].Trim()
+ $values = [System.Collections.Generic.List[object]]::new()
+ if ($inner) {
+ foreach ($item in $inner -split '\s*,\s*') {
+ $normalized = $item.Trim().Trim('"', "'")
+ $number = 0
+ if ($key -eq 'bc-version' -and [int]::TryParse($normalized, [ref]$number)) {
+ $values.Add($number) | Out-Null
+ }
+ else {
+ $values.Add($normalized) | Out-Null
+ }
+ }
+ }
+ $frontmatter[$key] = [object[]]@($values)
+ continue
+ }
+
+ $frontmatter[$key] = $value.Trim('"', "'")
+ }
+
+ return $frontmatter
+}
+
+$records = [System.Collections.Generic.List[object]]::new()
+$recordsByPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
+$sourceManifest = [System.Collections.Generic.List[object]]::new()
+
+foreach ($layer in 'microsoft', 'community', 'custom') {
+ $skillsRoot = Join-Path $BCQualityRoot (Join-Path $layer 'skills')
+ if (-not (Test-Path -LiteralPath $skillsRoot -PathType Container)) {
+ continue
+ }
+
+ foreach ($file in Get-ChildItem -LiteralPath $skillsRoot -Recurse -File -Filter '*.md' | Sort-Object FullName) {
+ $bytes = [IO.File]::ReadAllBytes($file.FullName)
+ try {
+ $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes)
+ }
+ catch [Text.DecoderFallbackException] {
+ throw [IO.InvalidDataException]::new("Invalid UTF-8 in '$($file.FullName)'.", $_.Exception)
+ }
+
+ $frontmatter = ConvertFrom-SkillFrontmatter -Path $file.FullName -Text $text
+ if ($frontmatter['kind'] -ne 'action-skill') {
+ continue
+ }
+
+ foreach ($required in 'id', 'version', 'title', 'description', 'inputs', 'outputs') {
+ if (-not $frontmatter.Contains($required) -or $null -eq $frontmatter[$required] -or
+ ([string]$frontmatter[$required]).Trim() -eq '') {
+ throw [IO.InvalidDataException]::new(
+ "Action skill '$($file.FullName)' is missing required frontmatter '$required'."
+ )
+ }
+ }
+
+ $path = Get-RelativePath -Root $BCQualityRoot -Full $file.FullName
+ $sourceSha256 = Get-Sha256 -Bytes $bytes
+ $version = 0
+ if (-not [int]::TryParse([string]$frontmatter['version'], [ref]$version) -or $version -le 0) {
+ throw [IO.InvalidDataException]::new("Action skill '$path' has an invalid version.")
+ }
+
+ $subSkills = @()
+ if ($frontmatter.Contains('sub-skills')) {
+ $subSkills = @($frontmatter['sub-skills'])
+ if (-not $subSkills.Count) {
+ throw [IO.InvalidDataException]::new("Super-skill '$path' has an empty sub-skills list.")
+ }
+ }
+
+ $record = [pscustomobject][ordered]@{
+ path = $path
+ layer = $layer
+ id = [string]$frontmatter['id']
+ version = $version
+ title = [string]$frontmatter['title']
+ description = [string]$frontmatter['description']
+ inputs = [string[]]@($frontmatter['inputs'])
+ outputs = [string[]]@($frontmatter['outputs'])
+ filters = [ordered]@{
+ 'bc-version' = [object[]]$(if ($frontmatter.Contains('bc-version')) { $frontmatter['bc-version'] })
+ technologies = [string[]]$(if ($frontmatter.Contains('technologies')) { $frontmatter['technologies'] })
+ countries = [string[]]$(if ($frontmatter.Contains('countries')) { $frontmatter['countries'] })
+ 'application-area' = [string[]]$(if ($frontmatter.Contains('application-area')) { $frontmatter['application-area'] })
+ }
+ subSkills = [string[]]$subSkills
+ sourceSha256 = $sourceSha256
+ }
+
+ if (-not $recordsByPath.TryAdd($path, $record)) {
+ throw "Duplicate action-skill path: $path"
+ }
+ $records.Add($record) | Out-Null
+ $sourceManifest.Add([ordered]@{ path = $path; sha256 = $sourceSha256 }) | Out-Null
+ }
+}
+
+$idsWithinLayer = @(
+ $records |
+ Group-Object { "$($_.layer)`0$($_.id)" } |
+ Where-Object Count -gt 1
+)
+if ($idsWithinLayer.Count) {
+ $duplicates = @(
+ $idsWithinLayer | ForEach-Object {
+ $parts = $_.Name -split "`0", 2
+ "$($parts[0]):$($parts[1])"
+ }
+ )
+ throw "Duplicate action-skill IDs within a layer: $($duplicates -join ', ')"
+}
+
+foreach ($record in $records) {
+ $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ foreach ($subSkillPath in @($record.subSkills)) {
+ if (-not $seen.Add($subSkillPath)) {
+ throw "Super-skill '$($record.path)' declares duplicate sub-skill '$subSkillPath'."
+ }
+ if (-not $recordsByPath.ContainsKey($subSkillPath)) {
+ throw "Super-skill '$($record.path)' references missing action skill '$subSkillPath'."
+ }
+
+ $leaf = $recordsByPath[$subSkillPath]
+ if (@($leaf.subSkills).Count) {
+ throw "Nested super-skills are not supported: '$($record.path)' references '$subSkillPath'."
+ }
+ if (@($leaf.outputs).Count -ne 1 -or $leaf.outputs[0] -ne 'findings-report') {
+ throw "Sub-skill '$subSkillPath' must produce findings-report."
+ }
+ }
+}
+
+$index = [ordered]@{
+ version = 1
+ generatedAt = (Get-Date).ToUniversalTime().ToString('o')
+ skillCount = $records.Count
+ sourceSnapshot = Get-ValueSha256 -Value @($sourceManifest)
+ skills = @($records)
+}
+
+$parent = Split-Path -Parent $IndexPath
+if ($parent -and -not (Test-Path -LiteralPath $parent)) {
+ New-Item -ItemType Directory -Path $parent -Force | Out-Null
+}
+Set-Content -LiteralPath $IndexPath -Value (
+ ConvertTo-Json -InputObject $index -Depth 12 -Compress
+) -Encoding utf8NoBOM
+
+return $records.Count
diff --git a/tools/Get-KnowledgeArticles.ps1 b/tools/Get-KnowledgeArticles.ps1
new file mode 100644
index 0000000..cdd112a
--- /dev/null
+++ b/tools/Get-KnowledgeArticles.ps1
@@ -0,0 +1,187 @@
+<#
+.SYNOPSIS
+ Reads a bounded prefix of exact article or sample paths without altering bodies.
+.DESCRIPTION
+ The UTF-8 byte size bound covers the complete serialized JSON plus its output
+ newline. A body that cannot fit fails explicitly; it is never summarized or
+ truncated. Samples are loaded only with -Samples and must be linked by their
+ sibling article using READ's exact link convention.
+#>
+#requires -Version 7.2
+[CmdletBinding()]
+param(
+ [ValidateNotNullOrEmpty()] [string] $BCQualityRoot = (Split-Path $PSScriptRoot -Parent),
+ [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string[]] $Paths,
+ [ValidateRange(1, 8)] [int] $MaxArticles = 8,
+ [ValidateRange(1024, 16000)] [int] $MaxBytes = 16000,
+ [ValidateSet('microsoft', 'community', 'custom')]
+ [AllowEmptyCollection()] [string[]] $EnabledLayers = @('microsoft', 'community', 'custom'),
+ [string] $IndexPath,
+ [ValidatePattern('^[a-f0-9]{64}$')] [string] $Snapshot,
+ [switch] $Samples
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1')
+. (Join-Path $PSScriptRoot 'Bounded-Results.ps1')
+
+$BCQualityRoot = Resolve-KnowledgeRoot $BCQualityRoot
+if (-not $IndexPath) {
+ $IndexPath = Join-Path $BCQualityRoot 'knowledge-index.json'
+}
+if ($Paths.Count -gt $MaxArticles) {
+ throw "Paths count $($Paths.Count) exceeds MaxArticles=$MaxArticles. Split the worklist into stable chunks of at most $MaxArticles exact paths."
+}
+if ($null -eq $EnabledLayers) {
+ throw 'EnabledLayers must be an array.'
+}
+if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or
+ @($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) {
+ throw 'EnabledLayers must contain unique canonical lowercase layer names.'
+}
+
+$recovery = "Run Entry preparation once before dispatch, or use READ's bounded native-file fallback. Do not rebuild in a leaf."
+$preparedIndex = Read-PreparedKnowledgeIndex -IndexPath $IndexPath -Recovery $recovery
+$index = $preparedIndex.index
+$byPath = $preparedIndex.byPath
+$unrestricted = $index.enabledLayers.Count -eq 0 -or
+ ($index.enabledLayers.Count -eq 1 -and $null -eq $index.enabledLayers[0])
+$indexedLayers = @(
+ if ($unrestricted) { 'microsoft', 'community', 'custom' } else { $index.enabledLayers }
+)
+if (@($EnabledLayers | Where-Object { $_ -cnotin $indexedLayers }).Count) {
+ throw "Index layer coverage does not cover EnabledLayers. $recovery"
+}
+
+$resolved = [Collections.Generic.List[string]]::new()
+$records = [Collections.Generic.List[object]]::new()
+$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+$articleTexts = [Collections.Generic.Dictionary[string, string]]::new([StringComparer]::Ordinal)
+$sampleContents = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
+foreach ($path in $Paths) {
+ $kind = if ($Samples) { 'sample' } else { 'article' }
+ $fullPath = Resolve-KnowledgePath -Root $BCQualityRoot -Path $path -Kind $kind
+ if ($path.Split('/')[0] -cnotin $EnabledLayers) {
+ throw "Layer disabled for path: $path"
+ }
+ if (-not $seen.Add($path)) {
+ throw "Duplicate requested path: $path"
+ }
+ if ($Samples) {
+ $articlePath = $path -replace '\.(good|bad)\.[a-z0-9]+$', '.md'
+ if (-not $byPath.ContainsKey($articlePath)) {
+ throw "Sample article is absent from the prepared index: $articlePath"
+ }
+ $fullArticlePath = Resolve-KnowledgePath -Root $BCQualityRoot -Path $articlePath
+ if (-not $articleTexts.ContainsKey($articlePath)) {
+ $articleContent = Read-KnowledgeText -Path $fullArticlePath
+ if ($articleContent.sha256 -cne $byPath[$articlePath].sourceSha256) {
+ throw "Selected article hash does not match the prepared index: $articlePath"
+ }
+ $articleTexts.Add($articlePath, $articleContent.text)
+ }
+ Assert-SampleLink -ArticleText $articleTexts[$articlePath] -SamplePath $fullPath
+ $sampleContent = Read-KnowledgeText -Path $fullPath
+ $sampleContents.Add($path, $sampleContent)
+ $records.Add([ordered]@{
+ path = $path
+ articlePath = $articlePath
+ articleSha256 = $byPath[$articlePath].sourceSha256
+ sampleSha256 = $sampleContent.sha256
+ sampleBytes = $sampleContent.bytes
+ })
+ }
+ else {
+ if (-not $byPath.ContainsKey($path)) {
+ throw "Selected article is absent from the prepared index: $path"
+ }
+ $records.Add([ordered]@{
+ path = $path
+ expectedSha256 = $byPath[$path].sourceSha256
+ })
+ }
+ $resolved.Add($fullPath)
+}
+
+$requestSnapshot = Get-ResultSnapshot -Value ([ordered]@{
+ root = $BCQualityRoot
+ preparedIndexSha256 = $preparedIndex.content.sha256
+ kind = if ($Samples) { 'samples' } else { 'articles' }
+ enabledLayers = @($EnabledLayers)
+ files = @($records)
+})
+if ($Snapshot -and $Snapshot -cne $requestSnapshot) {
+ throw 'Article snapshot changed or continuation belongs to another exact path batch. Discard partial results and restart.'
+}
+
+$articles = [Collections.Generic.List[object]]::new()
+function ConvertTo-BatchJson {
+ param([int] $ReadCount)
+
+ $remaining = @(
+ if ($ReadCount -lt $Paths.Count) {
+ $Paths[$ReadCount..($Paths.Count - 1)]
+ }
+ )
+ $remainingRecords = @(
+ if ($ReadCount -lt $records.Count) {
+ $records[$ReadCount..($records.Count - 1)]
+ }
+ )
+ $continuation = if ($remaining.Count) {
+ [ordered]@{
+ snapshot = Get-ResultSnapshot -Value ([ordered]@{
+ root = $BCQualityRoot
+ preparedIndexSha256 = $preparedIndex.content.sha256
+ kind = if ($Samples) { 'samples' } else { 'articles' }
+ enabledLayers = @($EnabledLayers)
+ files = $remainingRecords
+ })
+ }
+ }
+ else {
+ $null
+ }
+ return [ordered]@{
+ version = 1
+ kind = if ($Samples) { 'samples' } else { 'articles' }
+ snapshot = $requestSnapshot
+ requestedCount = $Paths.Count
+ returnedCount = $ReadCount
+ complete = ($ReadCount -eq $Paths.Count)
+ articles = @($articles)
+ remainingPaths = $remaining
+ continuation = $continuation
+ } | ConvertTo-Json -Depth 8 -Compress
+}
+
+$json = ''
+for ($i = 0; $i -lt [Math]::Min($MaxArticles, $Paths.Count); $i++) {
+ $content = if ($Samples) {
+ $sampleContents[$Paths[$i]]
+ }
+ else {
+ Read-KnowledgeText -Path $resolved[$i]
+ }
+ if (-not $Samples -and $content.sha256 -cne $records[$i].expectedSha256) {
+ throw "Selected article hash does not match the prepared index: $($Paths[$i])"
+ }
+ $articles.Add([ordered]@{
+ path = $Paths[$i]
+ bytes = $content.bytes
+ sha256 = $content.sha256
+ body = $content.text
+ })
+ $next = ConvertTo-BatchJson -ReadCount ($i + 1)
+ if ((Get-SerializedByteCount -Json $next) -gt $MaxBytes) {
+ $articles.RemoveAt($articles.Count - 1)
+ if ($i -eq 0) {
+ throw "No complete body plus continuation fits MaxBytes=$MaxBytes at $($Paths[$i]). Use a smaller exact path batch or READ's bounded native-file fallback; never truncate."
+ }
+ break
+ }
+ $json = $next
+}
+
+$json
diff --git a/tools/Knowledge-Retrieval.ps1 b/tools/Knowledge-Retrieval.ps1
new file mode 100644
index 0000000..7007868
--- /dev/null
+++ b/tools/Knowledge-Retrieval.ps1
@@ -0,0 +1,298 @@
+# Shared filesystem guards for catalog and exact article retrieval.
+Set-StrictMode -Version Latest
+
+function Resolve-KnowledgeRoot {
+ param([string] $Root)
+
+ $item = Get-Item -LiteralPath $Root -Force -ErrorAction Stop
+ if ($item.PSProvider.Name -ne 'FileSystem' -or -not $item.PSIsContainer) {
+ throw "BCQuality root must be a filesystem directory: $Root"
+ }
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ throw "Linked BCQuality roots are not supported: $Root"
+ }
+ return $item.FullName
+}
+
+function Assert-KnowledgePath {
+ param(
+ [string] $Path,
+ [ValidateSet('article', 'sample')] [string] $Kind = 'article'
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Path) -or
+ $Path -cnotmatch '^(microsoft|community|custom)/knowledge/[^/]+/.+' -or
+ $Path -match '[\\:*?"<>|\x00-\x1f]' -or
+ @($Path.Split('/') | Where-Object { $_ -in '', '.', '..' -or $_ -match '[. ]$' }).Count) {
+ throw "Invalid knowledge path: $Path"
+ }
+ if (($Kind -eq 'article' -and -not $Path.EndsWith('.md', [StringComparison]::Ordinal)) -or
+ ($Kind -eq 'sample' -and $Path -cnotmatch '\.(good|bad)\.[a-z0-9]+$')) {
+ throw "Expected an exact $Kind path: $Path"
+ }
+}
+
+function Resolve-KnowledgePath {
+ param(
+ [string] $Root,
+ [string] $Path,
+ [ValidateSet('article', 'sample')] [string] $Kind = 'article'
+ )
+
+ Assert-KnowledgePath -Path $Path -Kind $Kind
+ $current = $Root
+ foreach ($part in $Path.Split('/')) {
+ $items = @(
+ Get-ChildItem -LiteralPath $current -Filter $part -Force -ErrorAction Stop |
+ Where-Object Name -CEQ $part
+ )
+ if ($items.Count -ne 1) {
+ throw "Knowledge path does not exist with exact casing: $Path"
+ }
+ $item = $items[0]
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ throw "Linked knowledge paths are not supported: $Path"
+ }
+ $current = $item.FullName
+ }
+ if ($item.PSIsContainer) {
+ throw "Knowledge path is not a file: $Path"
+ }
+ return $item.FullName
+}
+
+function Read-KnowledgeText {
+ param([string] $Path)
+
+ $bytes = [IO.File]::ReadAllBytes($Path)
+ try {
+ $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes)
+ }
+ catch {
+ throw "Knowledge file is not valid strict UTF-8: $Path"
+ }
+ return [pscustomobject]@{
+ text = $text
+ bytes = $bytes.Length
+ sha256 = [Convert]::ToHexString(
+ [Security.Cryptography.SHA256]::HashData($bytes)
+ ).ToLowerInvariant()
+ }
+}
+
+function Get-NormalizedKnowledgeVersions {
+ param([string[]] $Values)
+
+ foreach ($value in $Values) {
+ if ($value -match '^"([^"]*)"$' -or $value -match "^'([^']*)'$") {
+ $Matches[1]
+ }
+ else {
+ $value
+ }
+ }
+}
+
+function Get-KnowledgeMetadataProblem {
+ param([Collections.IDictionary] $Row)
+
+ if ($Row['parsed'] -isnot [bool] -or -not $Row['parsed']) {
+ return 'unparsed frontmatter'
+ }
+ if ($Row['domain'] -isnot [string] -or
+ $Row['domain'] -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$') {
+ return 'missing/invalid domain'
+ }
+ foreach ($field in @('bc-version', 'technologies', 'countries', 'application-area', 'keywords')) {
+ if ($Row[$field] -isnot [array] -or -not $Row[$field].Count) {
+ return "missing/invalid $field"
+ }
+ foreach ($value in $Row[$field]) {
+ if ($value -isnot [string] -or [string]::IsNullOrWhiteSpace($value)) {
+ return "invalid $field value"
+ }
+ }
+ }
+ foreach ($field in @('title', 'description')) {
+ if ($Row[$field] -isnot [string] -or
+ [string]::IsNullOrWhiteSpace($Row[$field]) -or
+ $Row[$field] -match '[\r\n]') {
+ return "missing/invalid $field"
+ }
+ }
+
+ $versions = @(Get-NormalizedKnowledgeVersions -Values $Row['bc-version'])
+ if ($versions -ccontains 'all') {
+ if ($versions.Count -ne 1) {
+ return 'mixed bc-version sentinel'
+ }
+ }
+ elseif ($versions.Count -eq 1 -and $versions[0] -match '^(\d+)\.\.(\d+)?$') {
+ $start = [bigint]::Parse($Matches[1])
+ if ($start -le 0 -or ($Matches[2] -and [bigint]::Parse($Matches[2]) -le 0)) {
+ return 'invalid bc-version range bound'
+ }
+ if ($Matches[2] -and $start -gt [bigint]::Parse($Matches[2])) {
+ return 'descending bc-version range'
+ }
+ }
+ else {
+ foreach ($version in $versions) {
+ if ($version -notmatch '^\d+$' -or [bigint]::Parse($version) -le 0) {
+ return 'invalid bc-version'
+ }
+ }
+ }
+ if (@($Row.technologies | Where-Object { $_ -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$' }).Count) {
+ return 'invalid technologies'
+ }
+ if ($Row.technologies -ccontains 'all') {
+ return 'invalid technologies sentinel'
+ }
+ if ($Row.countries -ccontains 'w1') {
+ if ($Row.countries.Count -ne 1) {
+ return 'mixed countries sentinel'
+ }
+ }
+ elseif (@($Row.countries | Where-Object { $_ -cnotmatch '^[a-z]{2}$' }).Count) {
+ return 'invalid countries'
+ }
+ if (@($Row['application-area'] | Where-Object { $_ -cnotmatch '^(all|[a-z0-9]+(-[a-z0-9]+)*)$' }).Count) {
+ return 'invalid application-area'
+ }
+ if ($Row['application-area'] -ccontains 'all' -and $Row['application-area'].Count -ne 1) {
+ return 'mixed application-area sentinel'
+ }
+ if (@($Row.keywords | Where-Object { $_ -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$' }).Count) {
+ return 'invalid keywords'
+ }
+ return ''
+}
+
+function Get-PreparedManifestSha256 {
+ param(
+ [string[]] $Paths,
+ [Collections.Generic.Dictionary[string, object]] $ByPath
+ )
+
+ $hash = [Security.Cryptography.IncrementalHash]::CreateHash(
+ [Security.Cryptography.HashAlgorithmName]::SHA256
+ )
+ try {
+ $hash.AppendData([byte[]][char]'[')
+ for ($i = 0; $i -lt $Paths.Count; $i++) {
+ if ($i) {
+ $hash.AppendData([byte[]][char]',')
+ }
+ $row = [ordered]@{
+ path = $Paths[$i]
+ sha256 = $ByPath[$Paths[$i]].sourceSha256
+ }
+ $hash.AppendData([Text.Encoding]::UTF8.GetBytes(
+ (ConvertTo-Json -InputObject $row -Depth 8 -Compress)
+ ))
+ }
+ $hash.AppendData([byte[]][char]']')
+ return [Convert]::ToHexString($hash.GetHashAndReset()).ToLowerInvariant()
+ }
+ finally {
+ $hash.Dispose()
+ }
+}
+
+function Read-PreparedKnowledgeIndex {
+ param(
+ [string] $IndexPath,
+ [string] $Recovery
+ )
+
+ if (-not (Test-Path -LiteralPath $IndexPath -PathType Leaf)) {
+ throw "Knowledge index missing: $IndexPath. $Recovery"
+ }
+ $indexItem = Get-Item -LiteralPath $IndexPath -Force -ErrorAction Stop
+ if ($indexItem.PSProvider.Name -ne 'FileSystem' -or
+ ($indexItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
+ throw "Knowledge index must be an unlinked filesystem file: $IndexPath. $Recovery"
+ }
+
+ $content = Read-KnowledgeText -Path $indexItem.FullName
+ try {
+ $index = $content.text.TrimStart([char]0xfeff) |
+ ConvertFrom-Json -AsHashtable -ErrorAction Stop
+ }
+ catch {
+ throw "Malformed knowledge index JSON: $($_.Exception.Message). $Recovery"
+ }
+ if ($index -isnot [Collections.IDictionary] -or
+ $index.version -ne 1 -or
+ $index.articles -isnot [array] -or
+ $index.articleCount -ne $index.articles.Count -or
+ $index.enabledLayers -isnot [array] -or
+ $index.knowledgeAllow -isnot [array] -or
+ $index.knowledgeDeny -isnot [array] -or
+ $index.sourceSnapshot -isnot [string] -or
+ $index.sourceSnapshot -cnotmatch '^[a-f0-9]{64}$') {
+ throw "Invalid knowledge index envelope. $Recovery"
+ }
+
+ $generatedAt = [DateTimeOffset]::MinValue
+ if ($index.generatedAt -is [DateTime]) {
+ $generatedAt = [DateTimeOffset]$index.generatedAt
+ }
+ elseif (-not [DateTimeOffset]::TryParse(
+ [string]$index.generatedAt,
+ [Globalization.CultureInfo]::InvariantCulture,
+ [Globalization.DateTimeStyles]::RoundtripKind,
+ [ref]$generatedAt
+ )) {
+ throw "Invalid knowledge index generatedAt. $Recovery"
+ }
+ if ($generatedAt -gt [DateTimeOffset]::UtcNow.AddMinutes(1)) {
+ throw "Invalid knowledge index generatedAt. $Recovery"
+ }
+
+ $byPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
+ foreach ($row in $index.articles) {
+ if ($row -isnot [Collections.IDictionary] -or $row.path -isnot [string]) {
+ throw "Index row has no exact path. $Recovery"
+ }
+ Assert-KnowledgePath -Path $row.path
+ if ($row.layer -cne $row.path.Split('/')[0] -or
+ $row.layer -cnotin @('microsoft', 'community', 'custom')) {
+ throw "Invalid index layer: $($row.path). $Recovery"
+ }
+ if ($row.sourceSha256 -isnot [string] -or
+ $row.sourceSha256 -cnotmatch '^[a-f0-9]{64}$') {
+ throw "Invalid source hash in knowledge index: $($row.path). $Recovery"
+ }
+ if (-not $byPath.TryAdd($row.path, $row)) {
+ throw "Duplicate index path: $($row.path). $Recovery"
+ }
+ }
+
+ $paths = [string[]]@($byPath.Keys)
+ [Array]::Sort($paths, [StringComparer]::Ordinal)
+ if ((Get-PreparedManifestSha256 -Paths $paths -ByPath $byPath) -cne $index.sourceSnapshot) {
+ throw "Stale or internally inconsistent prepared index snapshot. $Recovery"
+ }
+
+ return [pscustomobject]@{
+ index = $index
+ content = $content
+ byPath = $byPath
+ paths = $paths
+ }
+}
+
+function Assert-SampleLink {
+ param(
+ [string] $ArticleText,
+ [string] $SamplePath
+ )
+
+ $sampleName = [IO.Path]::GetFileName($SamplePath)
+ $expected = '[`' + $sampleName + '`](' + $sampleName + ')'
+ if (-not $ArticleText.Contains($expected, [StringComparison]::Ordinal)) {
+ throw "Sample is not linked by its article using the READ convention: $sampleName"
+ }
+}
diff --git a/tools/Resolve-SkillWorklist.ps1 b/tools/Resolve-SkillWorklist.ps1
new file mode 100644
index 0000000..1e5e9f3
--- /dev/null
+++ b/tools/Resolve-SkillWorklist.ps1
@@ -0,0 +1,92 @@
+<#
+.SYNOPSIS
+ Resolves a super-skill's ordered leaf worklist across enabled layers.
+#>
+[CmdletBinding()]
+param(
+ [string] $BCQualityRoot,
+ [string] $IndexPath,
+ [Parameter(Mandatory)]
+ [string] $SuperSkillPath,
+ [string[]] $EnabledLayers = @('microsoft', 'community', 'custom'),
+ [string[]] $DisabledSkills = @()
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+if (-not $BCQualityRoot) {
+ $BCQualityRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+}
+$BCQualityRoot = (Resolve-Path -LiteralPath $BCQualityRoot).Path
+if (-not $IndexPath) {
+ $IndexPath = Join-Path $BCQualityRoot 'skill-index.json'
+}
+if (-not (Test-Path -LiteralPath $IndexPath -PathType Leaf)) {
+ & (Join-Path $PSScriptRoot 'Build-SkillIndex.ps1') -BCQualityRoot $BCQualityRoot -IndexPath $IndexPath | Out-Null
+}
+
+$knownLayers = @('microsoft', 'community', 'custom')
+$unknownLayers = @($EnabledLayers | Where-Object { $_ -cnotin $knownLayers })
+if ($unknownLayers.Count) {
+ throw "Unknown enabled layers: $($unknownLayers -join ', ')"
+}
+
+$index = Get-Content -LiteralPath $IndexPath -Raw | ConvertFrom-Json
+$skills = @($index.skills)
+$superSkills = @($skills | Where-Object path -CEQ $SuperSkillPath)
+if ($superSkills.Count -ne 1) {
+ throw "Expected one indexed super-skill at '$SuperSkillPath', found $($superSkills.Count)."
+}
+$superSkill = $superSkills[0]
+if (-not @($superSkill.subSkills).Count) {
+ throw "Action skill '$SuperSkillPath' is not a super-skill."
+}
+
+$precedence = @{ microsoft = 0; community = 1; custom = 2 }
+$resolved = [Collections.Generic.List[object]]::new()
+$skipped = [Collections.Generic.List[object]]::new()
+foreach ($declaredPath in @($superSkill.subSkills)) {
+ $declared = @($skills | Where-Object path -CEQ $declaredPath)
+ if ($declared.Count -ne 1) {
+ throw "Declared sub-skill '$declaredPath' is not uniquely indexed."
+ }
+
+ $candidates = @(
+ $skills |
+ Where-Object {
+ $_.id -CEQ $declared[0].id -and
+ $_.layer -cin $EnabledLayers -and
+ $_.path -cnotin $DisabledSkills -and
+ -not @($_.subSkills).Count
+ } |
+ Sort-Object @{ Expression = { $precedence[$_.layer] }; Descending = $true }, path
+ )
+ if (-not $candidates.Count) {
+ $skipped.Add([pscustomobject][ordered]@{
+ id = $declared[0].id
+ declaredPath = $declaredPath
+ reason = 'configuration'
+ }) | Out-Null
+ continue
+ }
+
+ $winner = $candidates[0]
+ $resolved.Add([pscustomobject][ordered]@{
+ id = $winner.id
+ path = $winner.path
+ version = $winner.version
+ layer = $winner.layer
+ declaredPath = $declaredPath
+ }) | Out-Null
+}
+
+return [pscustomobject][ordered]@{
+ superSkill = [pscustomobject][ordered]@{
+ id = $superSkill.id
+ path = $superSkill.path
+ version = $superSkill.version
+ }
+ subSkills = @($resolved)
+ skipped = @($skipped)
+}
\ No newline at end of file
diff --git a/tools/Search-Knowledge.ps1 b/tools/Search-Knowledge.ps1
new file mode 100644
index 0000000..ade280e
--- /dev/null
+++ b/tools/Search-Knowledge.ps1
@@ -0,0 +1,244 @@
+<#
+.SYNOPSIS
+ Returns bounded pages of every domain/layer/READ-applicable catalog row.
+.DESCRIPTION
+ Consumes Entry's prepared index read-only. Results are never ranked, sampled,
+ top-k limited, deduplicated by basename, or narrowed by query text. Omit an
+ unknown task dimension; an explicit empty array is a known empty set.
+#>
+#requires -Version 7.2
+[CmdletBinding()]
+param(
+ [ValidateNotNullOrEmpty()] [string] $BCQualityRoot = (Split-Path $PSScriptRoot -Parent),
+ [Parameter(Mandatory)] [ValidateNotNullOrEmpty()]
+ [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] [string] $Domain,
+ [ValidateSet('microsoft', 'community', 'custom')]
+ [AllowEmptyCollection()] [string[]] $EnabledLayers = @('microsoft', 'community', 'custom'),
+ [ValidateRange(1, 2147483647)] [int] $BCVersion,
+ [AllowEmptyCollection()] [string[]] $Technologies,
+ [AllowEmptyCollection()] [string[]] $Countries,
+ [AllowEmptyCollection()] [string[]] $ApplicationAreas,
+ [switch] $ExcludeConditional,
+ [string] $IndexPath,
+ [ValidateRange(1024, 16000)] [int] $MaxBytes = 16000,
+ [ValidateRange(0, 2147483647)] [int] $Offset = 0,
+ [ValidatePattern('^[a-f0-9]{64}$')] [string] $Snapshot
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1')
+. (Join-Path $PSScriptRoot 'Bounded-Results.ps1')
+
+$BCQualityRoot = Resolve-KnowledgeRoot $BCQualityRoot
+if (-not $IndexPath) {
+ $IndexPath = Join-Path $BCQualityRoot 'knowledge-index.json'
+}
+if ($null -eq $EnabledLayers) {
+ throw 'EnabledLayers must be an array; use an empty array to disable all layers.'
+}
+if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or
+ @($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) {
+ throw 'EnabledLayers must contain unique canonical lowercase layer names.'
+}
+
+$context = [ordered]@{}
+foreach ($pair in @(
+ @('BCVersion', 'bc-version'),
+ @('Technologies', 'technologies'),
+ @('Countries', 'countries'),
+ @('ApplicationAreas', 'application-area')
+)) {
+ if (-not $PSBoundParameters.ContainsKey($pair[0])) {
+ continue
+ }
+ $value = $PSBoundParameters[$pair[0]]
+ if ($null -eq $value) {
+ throw "Omit unknown context; do not pass null for $($pair[0])."
+ }
+ if ($pair[0] -ne 'BCVersion') {
+ foreach ($entry in $value) {
+ if ([string]::IsNullOrWhiteSpace($entry) -or $entry -cne $entry.Trim()) {
+ throw "Invalid context value for $($pair[0]): '$entry'"
+ }
+ }
+ }
+ $context[$pair[1]] = $value
+}
+if ($context.Contains('technologies') -and $context['technologies'] -ccontains 'all') {
+ throw "Technologies has no 'all' sentinel. Omit unknown context."
+}
+
+$recovery = "Run Entry preparation once before dispatch, or use READ's path-discovery fallback. Do not rebuild in a leaf."
+$preparedIndex = Read-PreparedKnowledgeIndex -IndexPath $IndexPath -Recovery $recovery
+$index = $preparedIndex.index
+$indexContent = $preparedIndex.content
+$byPath = $preparedIndex.byPath
+$paths = $preparedIndex.paths
+
+# The v1 generator historically serialized an omitted EnabledLayers parameter as [null].
+$unrestricted = $index.enabledLayers.Count -eq 0 -or
+ ($index.enabledLayers.Count -eq 1 -and $null -eq $index.enabledLayers[0])
+$indexedLayers = @(
+ if ($unrestricted) {
+ 'microsoft', 'community', 'custom'
+ }
+ else {
+ $index.enabledLayers
+ }
+)
+if (@($indexedLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or
+ @($indexedLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count -or
+ @($EnabledLayers | Where-Object { $_ -cnotin $indexedLayers }).Count) {
+ throw "Index layer coverage does not cover EnabledLayers. $recovery"
+}
+
+foreach ($path in $paths) {
+ $row = $byPath[$path]
+ if ($row.layer -cnotin $indexedLayers) {
+ throw "Index row layer is outside index coverage: $path. $recovery"
+ }
+ $problem = Get-KnowledgeMetadataProblem -Row $row
+ if ($problem) {
+ throw "Malformed knowledge index row at ${path}: $problem. $recovery"
+ }
+}
+
+$defaults = [ordered]@{
+ 'bc-version' = @('all')
+ technologies = @('al')
+ countries = @('w1')
+ 'application-area' = @('all')
+}
+$candidates = [Collections.Generic.List[object]]::new()
+$excluded = [Collections.Generic.List[object]]::new()
+foreach ($path in $paths) {
+ $row = $byPath[$path]
+ if ($row.domain -cne $Domain) {
+ continue
+ }
+
+ $unknown = [Collections.Generic.List[string]]::new()
+ $matchesContext = $true
+ foreach ($field in $defaults.Keys) {
+ $values = $row[$field]
+ if ($field -eq 'bc-version') {
+ $values = @(Get-NormalizedKnowledgeVersions -Values $values)
+ }
+ $sentinel = switch ($field) {
+ 'bc-version' { 'all' }
+ 'countries' { 'w1' }
+ 'application-area' { 'all' }
+ default { '' }
+ }
+ if ($sentinel -and $values -ccontains $sentinel) {
+ continue
+ }
+ if (-not $context.Contains($field)) {
+ $unknown.Add($field)
+ continue
+ }
+
+ $target = $context[$field]
+ $matched = $false
+ if ($field -eq 'bc-version') {
+ # Compare as bigint on both sides: metadata validation accepts bounds
+ # wider than Int32, and an int left operand would coerce them down.
+ $targetVersion = [bigint]$target
+ if ($values.Count -eq 1 -and $values[0] -match '^(\d+)\.\.(\d+)?$') {
+ $matched = $targetVersion -ge [bigint]::Parse($Matches[1]) -and
+ (-not $Matches[2] -or $targetVersion -le [bigint]::Parse($Matches[2]))
+ }
+ else {
+ $matched = @($values | Where-Object { [bigint]::Parse($_) -eq $targetVersion }).Count -gt 0
+ }
+ }
+ else {
+ $matched = @($values | Where-Object { $target -ccontains $_ }).Count -gt 0
+ }
+ if (-not $matched) {
+ $matchesContext = $false
+ break
+ }
+ }
+ if (-not $matchesContext -or ($ExcludeConditional -and $unknown.Count)) {
+ continue
+ }
+ $null = Resolve-KnowledgePath -Root $BCQualityRoot -Path $path
+
+ $candidate = [ordered]@{
+ path = $path
+ layer = $row.layer
+ keywords = $row.keywords
+ title = $row.title
+ description = $row.description
+ }
+ foreach ($field in $defaults.Keys) {
+ if (($row[$field] -join "`0") -cne ($defaults[$field] -join "`0")) {
+ $candidate[$field] = $row[$field]
+ }
+ }
+ $candidate.applicability = if ($unknown.Count) { 'conditional' } else { 'applicable' }
+ $candidate.unknownDimensions = @($unknown)
+ if ($row.layer -cin $EnabledLayers) {
+ $candidates.Add($candidate)
+ }
+ else {
+ $excluded.Add($candidate)
+ }
+}
+
+$header = [ordered]@{
+ version = 2
+ domain = $Domain
+ context = $context
+ enabledLayers = @($EnabledLayers)
+ indexedLayers = @($indexedLayers)
+ excludeConditional = [bool]$ExcludeConditional
+ defaults = $defaults
+ candidateCount = $candidates.Count
+ excludedByConfigurationCount = $excluded.Count
+}
+
+function Get-CatalogSnapshot {
+ param(
+ [string] $PreparedIndexSha256,
+ [Collections.IDictionary] $Request,
+ [Collections.IDictionary] $Groups
+ )
+
+ $hash = [Security.Cryptography.IncrementalHash]::CreateHash(
+ [Security.Cryptography.HashAlgorithmName]::SHA256
+ )
+ try {
+ foreach ($value in @(
+ $PreparedIndexSha256,
+ (ConvertTo-Json -InputObject $Request -Depth 8 -Compress)
+ )) {
+ $hash.AppendData([Text.Encoding]::UTF8.GetBytes($value))
+ $hash.AppendData([byte[]](10))
+ }
+ foreach ($groupName in $Groups.Keys) {
+ $hash.AppendData([Text.Encoding]::UTF8.GetBytes("[$groupName]"))
+ $hash.AppendData([byte[]](10))
+ foreach ($row in $Groups[$groupName]) {
+ $hash.AppendData([Text.Encoding]::UTF8.GetBytes(
+ (ConvertTo-Json -InputObject $row -Depth 8 -Compress)
+ ))
+ $hash.AppendData([byte[]](10))
+ }
+ }
+ return [Convert]::ToHexString($hash.GetHashAndReset()).ToLowerInvariant()
+ }
+ finally {
+ $hash.Dispose()
+ }
+}
+
+$groups = [ordered]@{
+ candidates = $candidates
+ excludedByConfiguration = $excluded
+}
+$header.snapshot = Get-CatalogSnapshot -PreparedIndexSha256 $indexContent.sha256 -Request $header -Groups $groups
+
+ConvertTo-BoundedPage -Header $header -Groups $groups -Offset $Offset -Snapshot $Snapshot -MaxBytes $MaxBytes
diff --git a/tools/Test-KnowledgeRetrieval.ps1 b/tools/Test-KnowledgeRetrieval.ps1
new file mode 100644
index 0000000..568a4bd
--- /dev/null
+++ b/tools/Test-KnowledgeRetrieval.ps1
@@ -0,0 +1,842 @@
+<#
+.SYNOPSIS
+ Validates lossless bounded catalog and exact-body retrieval.
+#>
+#requires -Version 7.2
+[CmdletBinding()]
+param(
+ [string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..'))
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$Root = (Resolve-Path -LiteralPath $Root).Path
+
+$generator = Join-Path $Root 'tools/Build-KnowledgeIndex.ps1'
+$search = Join-Path $Root 'tools/Search-Knowledge.ps1'
+$getArticles = Join-Path $Root 'tools/Get-KnowledgeArticles.ps1'
+$utf8 = [Text.UTF8Encoding]::new($false, $true)
+
+function Assert-True {
+ param([bool] $Condition, [string] $Message)
+ if (-not $Condition) {
+ throw "Assertion failed: $Message"
+ }
+}
+
+function Assert-Equal {
+ param($Actual, $Expected, [string] $Message)
+ if ($Actual -cne $Expected) {
+ throw "Assertion failed: $Message. Expected '$Expected', got '$Actual'."
+ }
+}
+
+function Assert-Sequence {
+ param($Actual, $Expected, [string] $Message)
+ $actualJson = ConvertTo-Json -InputObject @($Actual) -Compress
+ $expectedJson = ConvertTo-Json -InputObject @($Expected) -Compress
+ if ($actualJson -cne $expectedJson) {
+ throw "Assertion failed: $Message. Expected $expectedJson, got $actualJson."
+ }
+}
+
+function Assert-Throws {
+ param([scriptblock] $Action, [string] $Pattern, [string] $Message)
+ try {
+ & $Action
+ }
+ catch {
+ if ($_.Exception.Message -notmatch $Pattern) {
+ throw "Assertion failed: $Message. Wrong error: $($_.Exception.Message)"
+ }
+ return
+ }
+ throw "Assertion failed: $Message. No error was thrown."
+}
+
+function Get-OutputByteCount {
+ param([string] $Text)
+ return [Text.Encoding]::UTF8.GetByteCount($Text) +
+ [Text.Encoding]::UTF8.GetByteCount([Environment]::NewLine)
+}
+
+function Invoke-CatalogPages {
+ param(
+ [hashtable] $Arguments,
+ [int] $MaxBytes = 4096
+ )
+
+ $allCandidates = [Collections.Generic.List[object]]::new()
+ $allExcluded = [Collections.Generic.List[object]]::new()
+ $offset = 0
+ $snapshot = ''
+ $shared = ''
+ $pageCount = 0
+ $lastPage = $null
+ do {
+ $pageArguments = @{} + $Arguments
+ $pageArguments.MaxBytes = $MaxBytes
+ $pageArguments.Offset = $offset
+ if ($snapshot) {
+ $pageArguments.Snapshot = $snapshot
+ }
+ $raw = & $search @pageArguments
+ Assert-True ($raw -is [string]) 'catalog helper emitted exactly one JSON string'
+ Assert-True ((Get-OutputByteCount -Text $raw) -le $MaxBytes) 'catalog page includes its newline in MaxBytes'
+ $page = $raw | ConvertFrom-Json
+ $pageCount++
+ Assert-True ($pageCount -le 1000) 'catalog continuation terminates'
+ Assert-Equal $page.offset $offset 'catalog offset is exact'
+ Assert-Equal $page.returnedCount (@($page.candidates).Count + @($page.excludedByConfiguration).Count) 'page returnedCount matches rows'
+ Assert-Equal $page.remainingCount ($page.totalCount - $offset - $page.returnedCount) 'page remainingCount is exact'
+
+ $currentShared = [ordered]@{
+ version = $page.version
+ domain = $page.domain
+ context = $page.context
+ enabledLayers = $page.enabledLayers
+ indexedLayers = $page.indexedLayers
+ excludeConditional = $page.excludeConditional
+ defaults = $page.defaults
+ candidateCount = $page.candidateCount
+ excludedByConfigurationCount = $page.excludedByConfigurationCount
+ snapshot = $page.snapshot
+ totalCount = $page.totalCount
+ } | ConvertTo-Json -Depth 8 -Compress
+ if (-not $shared) {
+ $shared = $currentShared
+ $snapshot = $page.snapshot
+ }
+ else {
+ Assert-Equal $currentShared $shared 'catalog pages repeat shared context, defaults, totals, and snapshot'
+ }
+
+ foreach ($row in @($page.candidates)) {
+ $allCandidates.Add($row)
+ }
+ foreach ($row in @($page.excludedByConfiguration)) {
+ $allExcluded.Add($row)
+ }
+ if (-not $page.complete) {
+ Assert-True ($null -ne $page.continuation) 'incomplete page has continuation'
+ Assert-Equal $page.continuation.snapshot $snapshot 'continuation is snapshot-bound'
+ Assert-True ($page.continuation.offset -gt $offset) 'continuation makes progress'
+ $offset = $page.continuation.offset
+ }
+ $lastPage = $page
+ } while (-not $page.complete)
+
+ Assert-True ($null -eq $lastPage.continuation) 'final page has no continuation'
+ Assert-Equal $allCandidates.Count $lastPage.candidateCount 'candidate total survives paging'
+ Assert-Equal $allExcluded.Count $lastPage.excludedByConfigurationCount 'excluded total survives paging'
+ return [pscustomobject]@{
+ candidates = @($allCandidates)
+ excluded = @($allExcluded)
+ pages = $pageCount
+ snapshot = $snapshot
+ lastPage = $lastPage
+ }
+}
+
+function Test-BodyRoundTrip {
+ param(
+ [string[]] $Paths,
+ [string] $IndexPath,
+ [switch] $Samples
+ )
+
+ $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ for ($start = 0; $start -lt $Paths.Count; $start += 8) {
+ $end = [Math]::Min($start + 7, $Paths.Count - 1)
+ $remaining = @($Paths[$start..$end])
+ $snapshot = ''
+ do {
+ $arguments = @{
+ BCQualityRoot = $Root
+ IndexPath = $IndexPath
+ Paths = $remaining
+ MaxArticles = 8
+ MaxBytes = 16000
+ }
+ if ($Samples) {
+ $arguments.Samples = $true
+ }
+ if ($snapshot) {
+ $arguments.Snapshot = $snapshot
+ }
+ $raw = & $getArticles @arguments
+ Assert-True ($raw -is [string]) 'article helper emitted exactly one JSON string'
+ Assert-True ((Get-OutputByteCount -Text $raw) -le 16000) 'article batch includes its newline in MaxBytes'
+ $batch = $raw | ConvertFrom-Json
+ Assert-True ($batch.returnedCount -gt 0) 'article batching makes progress'
+ Assert-Equal $batch.returnedCount @($batch.articles).Count 'article returnedCount matches rows'
+ Assert-Equal $batch.complete (@($batch.remainingPaths).Count -eq 0) 'article completion matches remaining paths'
+ if ($batch.complete) {
+ Assert-True ($null -eq $batch.continuation) 'complete article batch has no continuation'
+ }
+ else {
+ Assert-True ($batch.continuation.snapshot -match '^[a-f0-9]{64}$') 'article continuation is snapshot-bound'
+ }
+
+ foreach ($article in @($batch.articles)) {
+ Assert-True ($seen.Add($article.path)) "body returned once: $($article.path)"
+ $fullPath = Join-Path $Root ($article.path.Replace('/', [IO.Path]::DirectorySeparatorChar))
+ $bytes = [IO.File]::ReadAllBytes($fullPath)
+ $text = $utf8.GetString($bytes)
+ $hash = [Convert]::ToHexString(
+ [Security.Cryptography.SHA256]::HashData($bytes)
+ ).ToLowerInvariant()
+ Assert-Equal $article.bytes $bytes.Length "byte count round-trips: $($article.path)"
+ Assert-Equal $article.sha256 $hash "SHA-256 round-trips: $($article.path)"
+ Assert-Equal $article.body $text "body round-trips: $($article.path)"
+ }
+ $remaining = @($batch.remainingPaths)
+ $snapshot = if ($batch.complete) { '' } else { $batch.continuation.snapshot }
+ } while ($remaining.Count)
+ }
+ Assert-Equal $seen.Count $Paths.Count 'every requested body round-trips without loss'
+}
+
+function New-NeutralArticle {
+ param(
+ [string] $FixtureRoot,
+ [string] $Layer,
+ [string] $Slug,
+ [string] $Version = 'all',
+ [string] $Technology = 'al',
+ [string] $Country = 'w1',
+ [string] $Area = 'all',
+ [string] $Title = 'Neutral retrieval example',
+ [string] $Description = 'Neutral retrieval metadata for deterministic tests.'
+ )
+
+ $directory = Join-Path $FixtureRoot "$Layer\knowledge\neutral"
+ New-Item -ItemType Directory -Force -Path $directory | Out-Null
+ $content = @"
+---
+bc-version: [$Version]
+domain: neutral
+keywords: [neutral, retrieval, deterministic]
+technologies: [$Technology]
+countries: [$Country]
+application-area: [$Area]
+---
+
+# $Title
+
+## Description
+
+$Description
+"@
+ Set-Content -LiteralPath (Join-Path $directory "$Slug.md") -Value $content -Encoding utf8NoBOM
+}
+
+function Test-InvalidSourceIndexing {
+ param(
+ [string] $FixtureRoot,
+ [string] $Field,
+ [string] $ValidValue,
+ [string] $InvalidValue
+ )
+
+ New-NeutralArticle -FixtureRoot $FixtureRoot -Layer microsoft -Slug valid-source
+ New-NeutralArticle -FixtureRoot $FixtureRoot -Layer community -Slug invalid-source
+ $articlePath = Join-Path $FixtureRoot 'community\knowledge\neutral\invalid-source.md'
+ $text = [IO.File]::ReadAllText($articlePath, $utf8)
+ $text = $text.Replace("$Field`: $ValidValue", "$Field`: $InvalidValue")
+ [IO.File]::WriteAllText($articlePath, $text, $utf8)
+
+ $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("$Field-index.json")
+ $generation = @(& $generator -BCQualityRoot $FixtureRoot -IndexPath $indexPath 3>&1)
+ $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] })
+ Assert-Equal $warnings.Count 1 "scalar $Field source emits one omission warning"
+ Assert-True (
+ $warnings[0].Message -match
+ "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-source\.md': frontmatter field '$([regex]::Escape($Field))' must use non-empty bracket-array syntax\."
+ ) "scalar $Field warning identifies the exact path and reason"
+ $prepared = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ Assert-Equal $prepared.articleCount 1 "scalar $Field source is omitted while its valid sibling is indexed"
+ Assert-Sequence $prepared.articles.path @('microsoft/knowledge/neutral/valid-source.md') "scalar $Field index contains only the valid sibling"
+ $catalog = & $search -BCQualityRoot $FixtureRoot -IndexPath $indexPath -Domain neutral |
+ ConvertFrom-Json
+ Assert-Sequence $catalog.candidates.path @('microsoft/knowledge/neutral/valid-source.md') "scalar $Field catalog retrieves the valid sibling"
+ $valid = & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath `
+ -Paths 'microsoft/knowledge/neutral/valid-source.md' |
+ ConvertFrom-Json
+ Assert-True $valid.complete "scalar $Field valid sibling body retrieves completely"
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath `
+ -Paths 'community/knowledge/neutral/invalid-source.md'
+ } 'Selected article is absent from the prepared index' "scalar $Field omitted source cannot be retrieved"
+}
+
+function Test-InvalidSemanticIndexing {
+ param(
+ [string] $FixtureRoot,
+ [string] $CaseName,
+ [string] $Field,
+ [string] $ValidValue,
+ [string] $InvalidValue,
+ [string] $ExpectedReason
+ )
+
+ New-NeutralArticle -FixtureRoot $FixtureRoot -Layer microsoft -Slug valid-source
+ New-NeutralArticle -FixtureRoot $FixtureRoot -Layer community -Slug invalid-source
+ $articlePath = Join-Path $FixtureRoot 'community\knowledge\neutral\invalid-source.md'
+ $text = [IO.File]::ReadAllText($articlePath, $utf8)
+ $text = $text.Replace("$Field`: $ValidValue", "$Field`: $InvalidValue")
+ [IO.File]::WriteAllText($articlePath, $text, $utf8)
+
+ $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("$CaseName-index.json")
+ $generation = @(& $generator -BCQualityRoot $FixtureRoot -IndexPath $indexPath 3>&1)
+ $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] })
+ Assert-Equal $warnings.Count 1 "$CaseName emits one omission warning"
+ Assert-Equal $warnings[0].Message "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-source.md': $ExpectedReason." "$CaseName warning identifies exact path and reason"
+
+ $prepared = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ Assert-Equal $prepared.articleCount 1 "$CaseName omits invalid source and retains valid sibling"
+ Assert-Sequence $prepared.articles.path @('microsoft/knowledge/neutral/valid-source.md') "$CaseName index contains only valid sibling"
+ Assert-True ($prepared.sourceSnapshot -match '^[a-f0-9]{64}$') "$CaseName source snapshot remains valid"
+ $validRow = $prepared.articles[0]
+ $manifest = @(
+ [ordered]@{ path = $validRow.path; sha256 = $validRow.sourceSha256 }
+ )
+ $manifestBytes = [Text.Encoding]::UTF8.GetBytes(
+ (ConvertTo-Json -InputObject $manifest -Depth 8 -Compress)
+ )
+ $expectedSnapshot = [Convert]::ToHexString(
+ [Security.Cryptography.SHA256]::HashData($manifestBytes)
+ ).ToLowerInvariant()
+ Assert-Equal $prepared.sourceSnapshot $expectedSnapshot "$CaseName source snapshot covers only retained rows"
+
+ $catalog = & $search -BCQualityRoot $FixtureRoot -IndexPath $indexPath -Domain neutral |
+ ConvertFrom-Json
+ Assert-Sequence $catalog.candidates.path @('microsoft/knowledge/neutral/valid-source.md') "$CaseName catalog retains valid sibling"
+ $valid = & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath `
+ -Paths 'microsoft/knowledge/neutral/valid-source.md' |
+ ConvertFrom-Json
+ Assert-True $valid.complete "$CaseName valid sibling body retrieves"
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath `
+ -Paths 'community/knowledge/neutral/invalid-source.md'
+ } 'Selected article is absent from the prepared index' "$CaseName invalid source cannot be retrieved"
+}
+
+function Test-InvalidEnabledLayers {
+ param(
+ [string] $FixtureRoot,
+ [string] $CaseName,
+ $Layers,
+ [string] $ExpectedPattern
+ )
+
+ $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("layers-$CaseName.json")
+ $arguments = @{
+ BCQualityRoot = $FixtureRoot
+ IndexPath = $indexPath
+ EnabledLayers = $Layers
+ }
+ Assert-Throws {
+ & $generator @arguments
+ } $ExpectedPattern "$CaseName EnabledLayers fails"
+ Assert-True (-not (Test-Path -LiteralPath $indexPath)) "$CaseName fails before index creation"
+}
+
+$tmp = Join-Path ([IO.Path]::GetTempPath()) ("bcquality_retrieval_" + [guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Force -Path $tmp | Out-Null
+try {
+ $indexPath = Join-Path $tmp 'knowledge-index.json'
+ & $generator -BCQualityRoot $Root -IndexPath $indexPath | Out-Null
+ $index = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+
+ # Check the declared finder route and real tool reachability, not model compliance.
+ $errorSkill = Get-Content -LiteralPath (Join-Path $Root 'microsoft/skills/review/al-error-handling-review.md') -Raw
+ $errorSource = [regex]::Match($errorSkill, '(?ms)^## Source\r?\n(.*?)(?=^## )').Groups[1].Value
+ $sourceDomains = @([regex]::Matches($errorSource, '-Domain ([a-z-]+)') | ForEach-Object { $_.Groups[1].Value })
+ Assert-Sequence $sourceDomains @('error-handling', 'web-services') 'error-handling declares its own and supplementary HTTP catalogs'
+ foreach ($cue in @('HttpClient.Get', 'HttpClient.Post', 'resolved call paths', 'same known task dimensions and enabled layers')) {
+ Assert-True ($errorSource.Contains($cue)) "supplementary source retains '$cue'"
+ }
+ $httpArticleNames = @(
+ [regex]::Matches($errorSource, '\]\(\.\./\.\./knowledge/web-services/([a-z-]+\.md)\)') |
+ ForEach-Object { $_.Groups[1].Value }
+ )
+ Assert-Sequence $httpArticleNames @(
+ 'handle-httpclient-platform-failure-before-response-access.md'
+ 'check-http-status-before-consuming-response-body.md'
+ ) 'supplementary source names only the two canonical HTTP articles'
+ $httpArguments = @{
+ BCQualityRoot = $Root
+ IndexPath = $indexPath
+ EnabledLayers = @('microsoft', 'community', 'custom')
+ Technologies = @('al')
+ BCVersion = 28
+ Countries = @('w1')
+ }
+ $ownCatalog = Invoke-CatalogPages -Arguments ($httpArguments + @{ Domain = $sourceDomains[0] })
+ Assert-True (-not @($ownCatalog.candidates | Where-Object { $_.path -like '*/knowledge/web-services/*' }).Count) 'the primary catalog does not silently expand domains'
+ $httpCatalog = Invoke-CatalogPages -Arguments ($httpArguments + @{ Domain = $sourceDomains[1] })
+ $httpRows = @($httpCatalog.candidates | Where-Object { $httpArticleNames -ccontains ($_.path -split '/')[-1] })
+ $expectedHttpPaths = @(
+ $index.articles |
+ Where-Object { $_.domain -ceq 'web-services' -and $httpArticleNames -ccontains ($_.path -split '/')[-1] } |
+ ForEach-Object path |
+ Sort-Object
+ )
+ foreach ($name in $httpArticleNames) {
+ Assert-True ($expectedHttpPaths -ccontains "microsoft/knowledge/web-services/$name") "canonical owner exists for $name"
+ }
+ Assert-Sequence @($httpRows.path | Sort-Object) $expectedHttpPaths 'supplementary selection preserves exact paths across layers'
+ Test-BodyRoundTrip -Paths $httpRows.path -IndexPath $indexPath
+ foreach ($excludedContext in @(
+ @{ EnabledLayers = @() }
+ @{ Technologies = @('javascript') }
+ )) {
+ $arguments = $httpArguments + @{ Domain = $sourceDomains[1] }
+ foreach ($key in $excludedContext.Keys) {
+ $arguments[$key] = $excludedContext[$key]
+ }
+ $catalog = Invoke-CatalogPages -Arguments $arguments
+ $selected = @($catalog.candidates | Where-Object { $httpArticleNames -ccontains ($_.path -split '/')[-1] })
+ Assert-Equal $selected.Count 0 'supplementary selection respects disabled layers and nonmatching technology'
+ }
+ Write-Host 'HTTP source contract and exact-body reachability passed; model routing was not evaluated.'
+
+ $diskArticlePaths = @(
+ foreach ($layer in 'microsoft', 'community', 'custom') {
+ $knowledge = Join-Path $Root "$layer\knowledge"
+ if (Test-Path -LiteralPath $knowledge) {
+ Get-ChildItem -LiteralPath $knowledge -Recurse -File -Filter '*.md' |
+ ForEach-Object {
+ [IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/')
+ }
+ }
+ }
+ ) | Sort-Object
+ Assert-Equal $index.articleCount $diskArticlePaths.Count 'index covers every current article'
+ Assert-True ($index.sourceSnapshot -match '^[a-f0-9]{64}$') 'index carries an exact source snapshot'
+
+ $allCatalogRows = [Collections.Generic.List[object]]::new()
+ $domains = @($index.articles.domain | Sort-Object -Unique)
+ foreach ($domain in $domains) {
+ $catalog = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $Root
+ IndexPath = $indexPath
+ Domain = $domain
+ }
+ Assert-Equal $catalog.excluded.Count 0 "all layers enabled for $domain"
+ foreach ($row in $catalog.candidates) {
+ $allCatalogRows.Add($row)
+ }
+ }
+
+ $expectedRows = @($index.articles | Sort-Object path)
+ $actualRows = @($allCatalogRows | Sort-Object path)
+ Assert-Equal $actualRows.Count $expectedRows.Count 'paged union has no top-k or query-based loss'
+ Assert-Sequence ($actualRows.path) ($expectedRows.path) 'paged union equals all READ-filtered candidates'
+ Assert-Equal @($actualRows.path | Sort-Object -Unique).Count $actualRows.Count 'catalog does not deduplicate distinct paths'
+
+ $defaults = [ordered]@{
+ 'bc-version' = @('all')
+ technologies = @('al')
+ countries = @('w1')
+ 'application-area' = @('all')
+ }
+ for ($i = 0; $i -lt $actualRows.Count; $i++) {
+ $actual = $actualRows[$i]
+ $expected = $expectedRows[$i]
+ Assert-Equal $actual.path $expected.path 'catalog preserves exact path'
+ Assert-Equal $actual.layer $expected.layer 'catalog preserves layer'
+ Assert-Sequence $actual.keywords $expected.keywords 'catalog preserves full keywords'
+ Assert-Equal $actual.title $expected.title 'catalog preserves title'
+ Assert-Equal $actual.description $expected.description 'catalog preserves one-line description'
+
+ $unknown = [Collections.Generic.List[string]]::new()
+ foreach ($field in $defaults.Keys) {
+ $expectedValues = @($expected.$field)
+ $sentinel = switch ($field) {
+ 'bc-version' { 'all' }
+ 'countries' { 'w1' }
+ 'application-area' { 'all' }
+ default { '' }
+ }
+ if (-not $sentinel -or $expectedValues -notcontains $sentinel) {
+ $unknown.Add($field)
+ }
+ $hasField = $actual.PSObject.Properties.Name -ccontains $field
+ if (($expectedValues -join "`0") -ceq (@($defaults[$field]) -join "`0")) {
+ Assert-True (-not $hasField) "default field is inherited from page: $field"
+ }
+ else {
+ Assert-True $hasField "non-default field survives paging: $field"
+ Assert-Sequence $actual.$field $expectedValues "non-default field is exact: $field"
+ }
+ }
+ Assert-Equal $actual.applicability ($(if ($unknown.Count) { 'conditional' } else { 'applicable' })) 'applicability verdict is explicit'
+ Assert-Sequence $actual.unknownDimensions @($unknown) 'unknown dimensions are explicit'
+ }
+
+ $performanceFirst = & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -MaxBytes 4096 |
+ ConvertFrom-Json
+ Assert-True (-not $performanceFirst.complete) 'large domain produces deterministic continuation'
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -MaxBytes 4096 -Offset $performanceFirst.continuation.offset
+ } 'Continuation requires Snapshot' 'continuation without snapshot fails'
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -Offset $performanceFirst.totalCount
+ } 'Invalid Offset' 'offset at total fails'
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -Offset 1 -Snapshot ('0' * 64)
+ } 'Snapshot changed' 'wrong snapshot fails'
+
+ $changedRawIndex = Join-Path $tmp 'changed-raw-index.json'
+ $changedRaw = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $changedRaw.generatedAt = [DateTimeOffset]::UtcNow.ToString('O')
+ $changedRaw | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $changedRawIndex -Encoding utf8NoBOM -NoNewline
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $changedRawIndex -Domain performance `
+ -MaxBytes 4096 -Offset $performanceFirst.continuation.offset `
+ -Snapshot $performanceFirst.continuation.snapshot
+ } 'Snapshot changed' 'continuation is bound to the exact prepared index bytes'
+
+ $malformedIndex = Join-Path $tmp 'malformed.json'
+ Set-Content -LiteralPath $malformedIndex -Value '{not-json' -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $malformedIndex -Domain performance
+ } 'Malformed knowledge index JSON' 'malformed JSON fails'
+
+ $unsafeIndex = Join-Path $tmp 'unsafe.json'
+ $unsafe = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $unsafe.articles[0].path = '../outside.md'
+ $unsafe | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $unsafeIndex -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $unsafeIndex -Domain performance
+ } 'Invalid knowledge path' 'unsafe indexed path fails'
+
+ $invalidRowIndex = Join-Path $tmp 'invalid-row.json'
+ $invalidRow = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $invalidRow.articles[0].keywords = @()
+ $invalidRow | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $invalidRowIndex -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $invalidRowIndex -Domain performance
+ } 'Malformed knowledge index row' 'malformed index row fails'
+
+ $semanticCorruptIndex = Join-Path $tmp 'semantic-corrupt-row.json'
+ $semanticCorrupt = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $semanticCorrupt.articles[0].countries = @('usa')
+ $semanticCorrupt | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $semanticCorruptIndex -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $semanticCorruptIndex -Domain performance
+ } 'Malformed knowledge index row.*invalid countries' 'search rejects semantically invalid external index rows'
+
+ $corruptSemanticCases = @(
+ @{ name = 'uppercase-all'; field = 'bc-version'; value = @('ALL'); reason = 'invalid bc-version' },
+ @{ name = 'uppercase-w1'; field = 'countries'; value = @('W1'); reason = 'invalid countries' },
+ @{ name = 'zero-open-range'; field = 'bc-version'; value = @('"0.."'); reason = 'invalid bc-version range bound' },
+ @{ name = 'zero-closed-range'; field = 'bc-version'; value = @('"0..0"'); reason = 'invalid bc-version range bound' }
+ )
+ foreach ($case in $corruptSemanticCases) {
+ $corruptPath = Join-Path $tmp ("corrupt-$($case.name).json")
+ $corrupt = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $corrupt.articles[0].PSObject.Properties[$case.field].Value = $case.value
+ $corrupt | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $corruptPath -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $corruptPath -Domain performance
+ } "Malformed knowledge index row.*$([regex]::Escape($case.reason))" "search rejects $($case.name) in an external index"
+ }
+
+ $invalidUtf8Root = Join-Path $tmp 'invalid-utf8-source'
+ New-NeutralArticle -FixtureRoot $invalidUtf8Root -Layer microsoft -Slug valid-catalog
+ New-NeutralArticle -FixtureRoot $invalidUtf8Root -Layer community -Slug invalid-utf8-source
+ $invalidUtf8Article = Join-Path $invalidUtf8Root 'community\knowledge\neutral\invalid-utf8-source.md'
+ $validBytes = [IO.File]::ReadAllBytes($invalidUtf8Article)
+ [IO.File]::WriteAllBytes($invalidUtf8Article, [byte[]]@($validBytes + @(0xc3, 0x28)))
+ $invalidUtf8Index = Join-Path $tmp 'invalid-utf8-index.json'
+ $generation = @(& $generator -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index 3>&1)
+ $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] })
+ Assert-Equal $warnings.Count 1 'malformed UTF-8 source emits one omission warning'
+ Assert-Equal $warnings[0].Message "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-utf8-source.md': invalid UTF-8." 'malformed UTF-8 warning identifies the exact path and reason'
+ $invalidUtf8Prepared = Get-Content -LiteralPath $invalidUtf8Index -Raw -Encoding utf8 |
+ ConvertFrom-Json
+ Assert-Equal $invalidUtf8Prepared.articleCount 1 'malformed UTF-8 source is omitted while its valid sibling is indexed'
+ Assert-Sequence $invalidUtf8Prepared.articles.path @('microsoft/knowledge/neutral/valid-catalog.md') 'malformed UTF-8 index contains only the valid sibling'
+ $validCatalog = & $search -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index -Domain neutral |
+ ConvertFrom-Json
+ Assert-Sequence $validCatalog.candidates.path @('microsoft/knowledge/neutral/valid-catalog.md') 'catalog retrieves the valid sibling after malformed UTF-8 omission'
+ $validBody = & $getArticles -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index `
+ -Paths 'microsoft/knowledge/neutral/valid-catalog.md' |
+ ConvertFrom-Json
+ Assert-True $validBody.complete 'valid sibling body retrieves after malformed UTF-8 omission'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index `
+ -Paths 'community/knowledge/neutral/invalid-utf8-source.md'
+ } 'Selected article is absent from the prepared index' 'omitted malformed UTF-8 source cannot be retrieved'
+
+ $scalarCases = @(
+ @{ field = 'bc-version'; valid = '[all]'; invalid = 'all' },
+ @{ field = 'keywords'; valid = '[neutral, retrieval, deterministic]'; invalid = 'neutral' },
+ @{ field = 'technologies'; valid = '[al]'; invalid = 'al' },
+ @{ field = 'countries'; valid = '[w1]'; invalid = 'w1' },
+ @{ field = 'application-area'; valid = '[all]'; invalid = 'all' }
+ )
+ foreach ($case in $scalarCases) {
+ Test-InvalidSourceIndexing -FixtureRoot (Join-Path $tmp "scalar-$($case.field)") `
+ -Field $case.field -ValidValue $case.valid -InvalidValue $case.invalid
+ }
+
+ $semanticCases = @(
+ @{ name = 'mixed-version-sentinel'; field = 'bc-version'; valid = '[all]'; invalid = '[all, 27]'; reason = 'mixed bc-version sentinel' },
+ @{ name = 'invalid-country'; field = 'countries'; valid = '[w1]'; invalid = '[usa]'; reason = 'invalid countries' },
+ @{ name = 'descending-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["28..27"]'; reason = 'descending bc-version range' },
+ @{ name = 'malformed-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '[twenty-seven]'; reason = 'invalid bc-version' },
+ @{ name = 'malformed-keyword'; field = 'keywords'; valid = '[neutral, retrieval, deterministic]'; invalid = '[neutral, Bad_Token, deterministic]'; reason = 'invalid keywords' },
+ @{ name = 'malformed-technology'; field = 'technologies'; valid = '[al]'; invalid = '[AL]'; reason = 'invalid technologies' },
+ @{ name = 'malformed-application-area'; field = 'application-area'; valid = '[all]'; invalid = '[finance_]'; reason = 'invalid application-area' },
+ @{ name = 'uppercase-version-sentinel'; field = 'bc-version'; valid = '[all]'; invalid = '[ALL]'; reason = 'invalid bc-version' },
+ @{ name = 'uppercase-country-sentinel'; field = 'countries'; valid = '[w1]'; invalid = '[W1]'; reason = 'invalid countries' },
+ @{ name = 'zero-open-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["0.."]'; reason = 'invalid bc-version range bound' },
+ @{ name = 'zero-closed-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["0..0"]'; reason = 'invalid bc-version range bound' }
+ )
+ foreach ($case in $semanticCases) {
+ Test-InvalidSemanticIndexing -FixtureRoot (Join-Path $tmp "semantic-$($case.name)") `
+ -CaseName $case.name -Field $case.field -ValidValue $case.valid `
+ -InvalidValue $case.invalid -ExpectedReason $case.reason
+ }
+
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain ('x' * 2000) -MaxBytes 1024
+ } 'Page envelope exceeds' 'oversized page envelope fails'
+
+ $articlePaths = @($index.articles.path | Sort-Object)
+ Assert-Sequence $articlePaths $diskArticlePaths 'exact article path union matches disk'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $Root -IndexPath $indexPath -Paths @($articlePaths[0..8])
+ } 'exceeds MaxArticles=8' 'exact retrieval rejects path batches larger than eight'
+ $samplePaths = @(
+ foreach ($layer in 'microsoft', 'community', 'custom') {
+ $knowledge = Join-Path $Root "$layer\knowledge"
+ if (Test-Path -LiteralPath $knowledge) {
+ Get-ChildItem -LiteralPath $knowledge -Recurse -File |
+ Where-Object Name -Match '\.(good|bad)\.[a-z0-9]+$' |
+ ForEach-Object {
+ [IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/')
+ }
+ }
+ }
+ ) | Sort-Object
+ Test-BodyRoundTrip -Paths $articlePaths -IndexPath $indexPath
+ Test-BodyRoundTrip -Paths $samplePaths -IndexPath $indexPath -Samples
+
+ $fixtureRoot = Join-Path $tmp 'neutral'
+ New-NeutralArticle -FixtureRoot $fixtureRoot -Layer microsoft -Slug default
+ New-NeutralArticle -FixtureRoot $fixtureRoot -Layer community -Slug versioned -Version '"27.."' -Technology javascript -Country dk -Area finance -Title 'Versioned neutral example'
+ New-NeutralArticle -FixtureRoot $fixtureRoot -Layer custom -Slug localized -Version 28 -Technology al -Country de -Area service -Title 'Localized neutral example'
+ $fixtureIndex = Join-Path $tmp 'neutral-index.json'
+ & $generator -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex | Out-Null
+
+ foreach ($case in @(
+ @{ name = 'uppercase'; layers = @('Microsoft'); pattern = 'unique canonical lowercase layer names' },
+ @{ name = 'duplicate'; layers = @('microsoft', 'microsoft'); pattern = 'unique canonical lowercase layer names' },
+ @{ name = 'unknown'; layers = @('partner'); pattern = 'unique canonical lowercase layer names' },
+ @{ name = 'null'; layers = $null; pattern = 'must be an array' }
+ )) {
+ Test-InvalidEnabledLayers -FixtureRoot $fixtureRoot -CaseName $case.name `
+ -Layers $case.layers -ExpectedPattern $case.pattern
+ }
+ $subsetIndex = Join-Path $tmp 'community-only-index.json'
+ & $generator -BCQualityRoot $fixtureRoot -IndexPath $subsetIndex `
+ -EnabledLayers @('community') | Out-Null
+ $subset = & $search -BCQualityRoot $fixtureRoot -IndexPath $subsetIndex `
+ -Domain neutral -EnabledLayers @('community') |
+ ConvertFrom-Json
+ Assert-Equal $subset.candidateCount 1 'valid EnabledLayers subset builds and is consumable'
+ Assert-Sequence $subset.candidates.path @('community/knowledge/neutral/versioned.md') 'valid subset contains only its exact layer'
+
+ $applicable = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $fixtureRoot
+ IndexPath = $fixtureIndex
+ Domain = 'neutral'
+ BCVersion = 28
+ Technologies = @('al', 'javascript')
+ Countries = @('dk', 'de')
+ ApplicationAreas = @('finance', 'service')
+ } -MaxBytes 16000
+ Assert-Equal $applicable.candidates.Count 3 'neutral layer/version rows all survive matching context'
+ Assert-True (@($applicable.candidates | Where-Object applicability -CEQ applicable).Count -eq 3) 'matching rows are applicable'
+ $versioned = $applicable.candidates | Where-Object path -CEQ 'community/knowledge/neutral/versioned.md'
+ Assert-Equal $versioned.layer community 'non-default layer survives'
+ Assert-Sequence $versioned.'bc-version' @('"27.."') 'original version metadata survives'
+ Assert-Equal $versioned.applicability applicable 'lowercase sentinels and positive open range remain applicable'
+ Assert-Sequence $versioned.technologies @('javascript') 'non-default technology survives'
+ Assert-Sequence $versioned.countries @('dk') 'non-default country survives'
+ Assert-Sequence $versioned.'application-area' @('finance') 'non-default application area survives'
+
+ # Metadata validation accepts range bounds wider than Int32, so version
+ # matching must compare as bigint rather than coercing the bound down.
+ $wideRoot = Join-Path $tmp 'wide-version'
+ New-NeutralArticle -FixtureRoot $wideRoot -Layer microsoft -Slug wide-closed -Version '"1..99999999999"'
+ New-NeutralArticle -FixtureRoot $wideRoot -Layer microsoft -Slug wide-open -Version '"99999999999.."'
+ $wideIndex = Join-Path $tmp 'wide-version-index.json'
+ & $generator -BCQualityRoot $wideRoot -IndexPath $wideIndex | Out-Null
+ $wide = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $wideRoot
+ IndexPath = $wideIndex
+ Domain = 'neutral'
+ BCVersion = 28
+ } -MaxBytes 16000
+ Assert-Sequence $wide.candidates.path @('microsoft/knowledge/neutral/wide-closed.md') 'bc-version bounds beyond Int32 compare without overflow'
+
+ $conditional = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $fixtureRoot
+ IndexPath = $fixtureIndex
+ Domain = 'neutral'
+ BCVersion = 28
+ Technologies = @('al', 'javascript')
+ } -MaxBytes 16000
+ $conditionalVersioned = $conditional.candidates |
+ Where-Object path -CEQ 'community/knowledge/neutral/versioned.md'
+ Assert-Equal $conditionalVersioned.applicability conditional 'unknown context produces conditional verdict'
+ Assert-Sequence $conditionalVersioned.unknownDimensions @('countries', 'application-area') 'unknown dimensions survive'
+
+ $layerFiltered = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $fixtureRoot
+ IndexPath = $fixtureIndex
+ Domain = 'neutral'
+ EnabledLayers = @('microsoft')
+ } -MaxBytes 16000
+ Assert-Equal $layerFiltered.candidates.Count 1 'enabled layer remains a candidate'
+ Assert-Equal $layerFiltered.excluded.Count 2 'disabled layers remain explicit'
+ Assert-Sequence ($layerFiltered.excluded.layer | Sort-Object) @('community', 'custom') 'excluded rows preserve layer'
+
+ $oldSnapshot = $conditional.snapshot
+ Add-Content -LiteralPath (Join-Path $fixtureRoot 'community\knowledge\neutral\versioned.md') -Value ' ' -Encoding utf8NoBOM
+ $preparedCatalog = & $search -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex -Domain neutral |
+ ConvertFrom-Json
+ Assert-Equal $preparedCatalog.candidateCount 3 'catalog uses the prepared index without rehashing article bodies'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex `
+ -Paths 'community/knowledge/neutral/versioned.md'
+ } 'Selected article hash does not match the prepared index' 'exact retrieval detects selected article changes'
+ & $generator -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex | Out-Null
+ Assert-Throws {
+ & $search -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex -Domain neutral -Offset 1 -Snapshot $oldSnapshot
+ } 'Snapshot changed' 'continuation cannot cross rebuilt snapshots'
+
+ $largeRoot = Join-Path $tmp 'large-catalog'
+ New-NeutralArticle -FixtureRoot $largeRoot -Layer microsoft -Slug huge-title -Title ('T' * 3000)
+ $largeIndex = Join-Path $tmp 'large-index.json'
+ & $generator -BCQualityRoot $largeRoot -IndexPath $largeIndex | Out-Null
+ Assert-Throws {
+ & $search -BCQualityRoot $largeRoot -IndexPath $largeIndex -Domain neutral -MaxBytes 1024
+ } 'One complete candidates row|Page envelope exceeds' 'oversized catalog row fails without clipping'
+
+ # The shared pager reports the oversized row's identity for any row shape;
+ # a row without a path must still reach its explicit offset-based failure.
+ . (Join-Path $Root 'tools/Bounded-Results.ps1')
+ $pagerHeader = [ordered]@{ version = 2; snapshot = ('0' * 64) }
+ foreach ($shape in @(
+ @{ name = 'dictionary'; row = [ordered]@{ blob = ('x' * 3000) } },
+ @{ name = 'object'; row = [pscustomobject]@{ blob = ('x' * 3000) } }
+ )) {
+ Assert-Throws {
+ ConvertTo-BoundedPage -Header $pagerHeader `
+ -Groups ([ordered]@{ rows = @($shape.row) }) -MaxBytes 1024
+ } 'One complete rows row plus envelope exceeds MaxBytes=1024 at Offset=0' "oversized pathless $($shape.name) row fails with its offset identity"
+ }
+
+ $bodyRoot = Join-Path $tmp 'body-failures'
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug huge-body -Description ('x' * 3000)
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug broken-link
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug continuation-one -Description ('a' * 300)
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug continuation-two -Description ('b' * 300)
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug invalid-utf8
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug sample-one
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug sample-two
+ Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-one.md') `
+ -Value '[`sample-one.good.al`](sample-one.good.al)' -Encoding utf8NoBOM
+ Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.md') `
+ -Value '[`sample-two.good.al`](sample-two.good.al)' -Encoding utf8NoBOM
+ Set-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-one.good.al') `
+ -Value ('a' * 900) -Encoding utf8NoBOM
+ Set-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.good.al') `
+ -Value ('b' * 900) -Encoding utf8NoBOM
+ $bodyIndex = Join-Path $tmp 'body-index.json'
+ & $generator -BCQualityRoot $bodyRoot -IndexPath $bodyIndex | Out-Null
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths 'microsoft/knowledge/neutral/huge-body.md' -MaxBytes 1024
+ } 'No complete body plus continuation fits' 'oversized body fails without truncation'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex -Paths '../outside.md'
+ } 'Invalid knowledge path' 'unsafe requested path fails'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths 'microsoft/knowledge/neutral/huge-body.md' -EnabledLayers community
+ } 'Layer disabled' 'disabled article layer fails'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex -Paths @(
+ 'microsoft/knowledge/neutral/huge-body.md',
+ 'microsoft/knowledge/neutral/huge-body.md'
+ )
+ } 'Duplicate requested path' 'duplicate exact paths fail'
+
+ $brokenSample = Join-Path $bodyRoot 'microsoft\knowledge\neutral\broken-link.good.al'
+ Set-Content -LiteralPath $brokenSample -Value 'codeunit 1 Neutral { }' -Encoding utf8NoBOM
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths 'microsoft/knowledge/neutral/broken-link.good.al' -Samples
+ } 'Sample is not linked' 'unlinked sample fails'
+
+ $sampleContinuationPaths = @(
+ 'microsoft/knowledge/neutral/sample-one.good.al',
+ 'microsoft/knowledge/neutral/sample-two.good.al'
+ )
+ $firstSamplePage = & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths $sampleContinuationPaths -Samples -MaxBytes 1600 |
+ ConvertFrom-Json
+ Assert-True (-not $firstSamplePage.complete) 'bounded sample batch produces continuation'
+ Assert-Sequence $firstSamplePage.remainingPaths @('microsoft/knowledge/neutral/sample-two.good.al') 'sample continuation preserves pending path'
+ Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.good.al') `
+ -Value 'changed' -Encoding utf8NoBOM
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths @($firstSamplePage.remainingPaths) -Samples `
+ -Snapshot $firstSamplePage.continuation.snapshot
+ } 'Article snapshot changed' 'sample continuation rejects a changed pending sample'
+
+ $continuationPaths = @(
+ 'microsoft/knowledge/neutral/continuation-one.md',
+ 'microsoft/knowledge/neutral/continuation-two.md'
+ )
+ $firstBodyPage = & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths $continuationPaths -MaxBytes 1300 |
+ ConvertFrom-Json
+ Assert-True (-not $firstBodyPage.complete) 'bounded article batch produces continuation'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths @($firstBodyPage.remainingPaths) -Snapshot ('0' * 64)
+ } 'Article snapshot changed' 'wrong article continuation snapshot fails'
+ Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\continuation-two.md') -Value 'changed' -Encoding utf8NoBOM
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths @($firstBodyPage.remainingPaths) -Snapshot $firstBodyPage.continuation.snapshot
+ } 'Selected article hash does not match the prepared index' 'article continuation rejects a changed remaining body'
+
+ $invalidUtf8 = Join-Path $bodyRoot 'microsoft\knowledge\neutral\invalid-utf8.md'
+ $indexedBytes = [IO.File]::ReadAllBytes($invalidUtf8)
+ [IO.File]::WriteAllBytes($invalidUtf8, [byte[]]@($indexedBytes + @(0xc3, 0x28)))
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths 'microsoft/knowledge/neutral/invalid-utf8.md'
+ } 'Knowledge file is not valid strict UTF-8' 'invalid UTF-8 fails'
+
+ Write-Host "Knowledge retrieval check PASSED: $($articlePaths.Count) articles and $($samplePaths.Count) samples round-tripped; catalog union was lossless and bounded." -ForegroundColor Green
+}
+finally {
+ Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
+}
diff --git a/tools/Test-ReviewContract.ps1 b/tools/Test-ReviewContract.ps1
new file mode 100644
index 0000000..db1c1b1
--- /dev/null
+++ b/tools/Test-ReviewContract.ps1
@@ -0,0 +1,922 @@
+<#
+.SYNOPSIS
+ Validates executable findings-report acceptance and bounded normalization.
+
+.DESCRIPTION
+ These assertions keep the normative DO contract, executable validator, AL
+ coordinator, and standalone runner aligned while exercising semantic report
+ validation and the exact normalization predicate.
+#>
+[CmdletBinding()]
+param(
+ [string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..'))
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$Root = (Resolve-Path -LiteralPath $Root).Path
+
+function Assert-True {
+ param(
+ [bool] $Condition,
+ [string] $Message
+ )
+
+ if (-not $Condition) {
+ throw "Assertion failed: $Message"
+ }
+}
+
+function Assert-Contains {
+ param(
+ [string] $Text,
+ [string] $Expected,
+ [string] $Message
+ )
+
+ Assert-True $Text.Contains($Expected) $Message
+}
+
+function Assert-ThrowsLike {
+ param(
+ [scriptblock] $Action,
+ [string] $Pattern
+ )
+
+ try {
+ & $Action
+ }
+ catch {
+ if ($_.Exception.Message -like $Pattern) {
+ return
+ }
+ throw "Expected error like '$Pattern', received: $($_.Exception.Message)"
+ }
+ throw "Expected error like '$Pattern', but no error was thrown."
+}
+
+function Test-PositiveInteger {
+ param([object] $Value)
+
+ if (($null -eq $Value) -or ($Value -is [bool]) -or ($Value -isnot [ValueType])) {
+ return $false
+ }
+
+ $number = [double]$Value
+ return [double]::IsFinite($number) -and ($number -gt 0) -and ([math]::Truncate($number) -eq $number)
+}
+
+function Test-RangeNormalizationEligibility {
+ param([pscustomobject] $Finding)
+
+ if ($Finding.PSObject.Properties.Name -contains 'suggested-code') {
+ return $false
+ }
+ if (-not ($Finding.PSObject.Properties.Name -contains 'location')) {
+ return $false
+ }
+ if (-not ($Finding.location.PSObject.Properties.Name -contains 'line')) {
+ return $false
+ }
+ if (-not ($Finding.location.PSObject.Properties.Name -contains 'range')) {
+ return $false
+ }
+
+ $range = $Finding.location.range
+ if (-not ($range.PSObject.Properties.Name -contains 'start-line') -or
+ -not ($range.PSObject.Properties.Name -contains 'end-line')) {
+ return $false
+ }
+
+ $line = $Finding.location.line
+ $startLine = $range.'start-line'
+ $endLine = $range.'end-line'
+ if (-not (Test-PositiveInteger $line) -or
+ -not (Test-PositiveInteger $startLine) -or
+ -not (Test-PositiveInteger $endLine)) {
+ return $false
+ }
+
+ return ($startLine -le $line) -and ($line -le $endLine) -and ($startLine -ne $line)
+}
+
+$transportSentence = 'Capture the exact Task return as the immutable raw audit payload and primary transport.'
+$doContract = Get-Content -LiteralPath (Join-Path $Root 'skills/do.md') -Raw
+$coordinatorContract = Get-Content -LiteralPath (Join-Path $Root 'microsoft/skills/review/al-code-review.md') -Raw
+$runnerContract = Get-Content -LiteralPath (Join-Path $Root 'docs/standalone-runner.md') -Raw
+
+foreach ($surface in @(
+ [pscustomobject]@{ Name = 'DO'; Text = ($doContract -replace '\s+', ' ') }
+ [pscustomobject]@{ Name = 'AL coordinator'; Text = ($coordinatorContract -replace '\s+', ' ') }
+ [pscustomobject]@{ Name = 'standalone runner'; Text = ($runnerContract -replace '\s+', ' ') }
+)) {
+ Assert-Contains $surface.Text $transportSentence "$($surface.Name) preserves exact Task transport wording"
+}
+
+$normalizedDoContract = $doContract -replace '\s+', ' '
+foreach ($expected in @(
+ 'positive integers',
+ 'start-line <= line <= end-line',
+ 'does not contain the `suggested-code` field',
+ 'remove only',
+ 'private run telemetry or artifacts',
+ 'Validate the entire normalized candidate',
+ 'If any other validation defect exists',
+ 'salvage arbitrary individual findings'
+)) {
+ Assert-Contains $normalizedDoContract $expected "DO documents '$expected'"
+}
+
+$cases = @(
+ [pscustomobject]@{
+ Name = 'contained mismatched range without suggested code'
+ Expected = $true
+ Finding = '{"message":"keep me","location":{"file":"src/codeunit.al","line":37,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'aligned range'
+ Expected = $false
+ Finding = '{"location":{"line":37,"range":{"start-line":37,"end-line":38}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'suggested code present'
+ Expected = $false
+ Finding = '{"location":{"line":37,"range":{"start-line":36,"end-line":38}},"suggested-code":""}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'line outside range'
+ Expected = $false
+ Finding = '{"location":{"line":39,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'reversed range'
+ Expected = $false
+ Finding = '{"location":{"line":37,"range":{"start-line":38,"end-line":36}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'zero bound'
+ Expected = $false
+ Finding = '{"location":{"line":1,"range":{"start-line":0,"end-line":2}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'fractional primary line'
+ Expected = $false
+ Finding = '{"location":{"line":37.5,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'missing end line'
+ Expected = $false
+ Finding = '{"location":{"line":37,"range":{"start-line":36}}}' | ConvertFrom-Json
+ }
+)
+
+foreach ($case in $cases) {
+ $actual = Test-RangeNormalizationEligibility $case.Finding
+ Assert-True ($actual -eq $case.Expected) "$($case.Name) eligibility is $($case.Expected)"
+}
+
+$rawFinding = $cases[0].Finding
+$candidateFinding = $rawFinding | ConvertTo-Json -Depth 10 | ConvertFrom-Json
+$candidateFinding.location.PSObject.Properties.Remove('range')
+
+Assert-True ($rawFinding.location.PSObject.Properties.Name -contains 'range') 'raw finding remains unchanged'
+Assert-True (-not ($candidateFinding.location.PSObject.Properties.Name -contains 'range')) 'candidate removes only the optional range'
+Assert-True ($candidateFinding.location.line -eq $rawFinding.location.line) 'candidate preserves the primary line'
+Assert-True ($candidateFinding.message -ceq $rawFinding.message) 'candidate preserves all other finding content'
+
+$validator = Join-Path $Root 'tools/Validate-FindingsReport.ps1'
+Assert-True (Test-Path -LiteralPath $validator -PathType Leaf) 'executable report validator exists'
+$tmp = Join-Path ([IO.Path]::GetTempPath()) ("reviewcontract_" + [guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Path $tmp -Force | Out-Null
+try {
+ $sourcePath = 'src/codeunit.al'
+ $sourceFile = Join-Path $tmp 'src/codeunit.al'
+ New-Item -ItemType Directory -Path (Split-Path -Parent $sourceFile) -Force | Out-Null
+ Set-Content -LiteralPath $sourceFile -Value @('line one', 'line two', 'line three') -Encoding utf8NoBOM
+ $articlePath = 'microsoft/knowledge/style/caption-required-on-page-fields.md'
+ $supportingArticlePath = 'microsoft/knowledge/style/tooltip-required-on-page-fields.md'
+ $reportPath = Join-Path $tmp 'report.json'
+
+ $validReport = [ordered]@{
+ skill = [ordered]@{ id = 'al-style-review'; version = 1 }
+ outcome = 'completed'
+ summary = [ordered]@{
+ counts = [ordered]@{ blocker = 0; major = 0; minor = 1; info = 0 }
+ coverage = [ordered]@{ 'worklist-size' = 1; 'items-evaluated' = 1 }
+ }
+ findings = @(
+ [ordered]@{
+ id = $articlePath
+ severity = 'minor'
+ message = 'A concrete style defect.'
+ location = [ordered]@{
+ file = $sourcePath
+ line = 2
+ range = [ordered]@{ 'start-line' = 2; 'end-line' = 3 }
+ }
+ references = @([ordered]@{ path = $articlePath })
+ confidence = 'high'
+ domain = 'Style'
+ }
+ )
+ suppressed = @()
+ }
+ Set-Content -LiteralPath $reportPath -Value ($validReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $accepted = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp `
+ -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath
+ Assert-True (-not $accepted.normalized) 'valid report is accepted without normalization'
+
+ $invalidCounts = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $invalidCounts.summary.counts.minor = 0
+ Set-Content -LiteralPath $reportPath -Value ($invalidCounts | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*COUNT_MISMATCH*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp `
+ -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath
+ }
+
+ $completedUndercoverage = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $completedUndercoverage.summary.coverage.'items-evaluated' = 0
+ Set-Content -LiteralPath $reportPath -Value ($completedUndercoverage | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*COMPLETED_COVERAGE_INCOMPLETE*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp `
+ -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath
+ }
+
+ $partialFullCoverage = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $partialFullCoverage.outcome = 'partial'
+ $partialFullCoverage | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'Stopped early.'
+ Set-Content -LiteralPath $reportPath -Value ($partialFullCoverage | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*PARTIAL_COVERAGE_INVALID*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp `
+ -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath
+ }
+
+ $completedLeaf = [ordered]@{
+ skill = [ordered]@{ id = 'al-style-review'; version = 1 }
+ outcome = 'completed'
+ summary = [ordered]@{
+ counts = [ordered]@{ blocker = 0; major = 0; minor = 0; info = 0 }
+ coverage = [ordered]@{ 'worklist-size' = 1; 'items-evaluated' = 1 }
+ }
+ findings = @()
+ suppressed = @()
+ }
+ $leafWithSubResults = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $leafWithSubResults | Add-Member -NotePropertyName 'sub-results' -NotePropertyValue @($completedLeaf)
+ Set-Content -LiteralPath $reportPath -Value ($leafWithSubResults | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*LEAF_COMPOSITION_INVALID*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root
+ }
+
+ $completedSecurityLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $completedSecurityLeaf.skill.id = 'al-security-review'
+ $validSuperReport = [ordered]@{
+ skill = [ordered]@{ id = 'al-code-review'; version = 1 }
+ outcome = 'completed'
+ summary = [ordered]@{
+ counts = [ordered]@{ blocker = 0; major = 0; minor = 0; info = 0 }
+ coverage = [ordered]@{ 'worklist-size' = 2; 'items-evaluated' = 2 }
+ }
+ findings = @()
+ suppressed = @()
+ 'sub-results' = @($completedLeaf, $completedSecurityLeaf)
+ }
+ Set-Content -LiteralPath $reportPath -Value ($validSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $acceptedSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super
+ Assert-True (-not $acceptedSuper.normalized) 'valid super-skill report is accepted'
+
+ $duplicateLeafReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $duplicateLeafReport.'sub-results' = @($completedLeaf, $completedLeaf)
+ Set-Content -LiteralPath $reportPath -Value ($duplicateLeafReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*SUPER_DUPLICATE_SUB_RESULT*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super
+ }
+
+ $compositionPath = Join-Path $tmp 'composition.json'
+ function Save-AcceptedLeafReports {
+ param([object[]] $LeafReports)
+
+ foreach ($leafReport in $LeafReports) {
+ $leafPath = Join-Path $tmp "$([guid]::NewGuid()).json"
+ Set-Content -LiteralPath $leafPath -Value ($leafReport | ConvertTo-Json -Depth 100) -Encoding utf8NoBOM
+ $accepted = & $validator -ReportPath $leafPath -BCQualityRoot $Root
+ Assert-True (-not $accepted.normalized) 'host captures a validated leaf report'
+ @{ id = $leafReport.skill.id; version = $leafReport.skill.version; reportPath = $leafPath }
+ }
+ }
+
+ $expectedComposition = [ordered]@{
+ superSkill = @{ id = 'al-code-review'; version = 1 }
+ subSkills = @(
+ @{ id = 'al-style-review'; version = 1 }
+ @{ id = 'al-security-review'; version = 1 }
+ )
+ skipped = @()
+ acceptedResults = @(Save-AcceptedLeafReports @($completedLeaf, $completedSecurityLeaf))
+ }
+ Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Set-Content -LiteralPath $reportPath -Value ($validSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $acceptedBoundSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -ExpectedCompositionPath $compositionPath
+ Assert-True (-not $acceptedBoundSuper.normalized) 'complete composition matches the expected worklist'
+
+ $incompleteComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $incompleteComposition.acceptedResults = @($incompleteComposition.acceptedResults[0])
+ Set-Content -LiteralPath $compositionPath -Value ($incompleteComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $missingLeafReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $missingLeafReport.'sub-results' = @($completedLeaf)
+ $missingLeafReport.summary.coverage.'worklist-size' = 1
+ $missingLeafReport.summary.coverage.'items-evaluated' = 1
+ Set-Content -LiteralPath $reportPath -Value ($missingLeafReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*SUPER_OUTCOME_MISMATCH*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super -ExpectedCompositionPath $compositionPath
+ }
+ $missingLeafReport.outcome = 'partial'
+ $missingLeafReport | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'al-security-review was not evaluated before the budget expired.'
+ Set-Content -LiteralPath $reportPath -Value ($missingLeafReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $acceptedIncompleteSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -ExpectedCompositionPath $compositionPath
+ Assert-True ($acceptedIncompleteSuper.report.outcome -ceq 'partial') 'unfinished selected leaves require a truthful partial outcome'
+
+ function Assert-CompositionReport {
+ param([object] $Candidate, [string] $ErrorPattern)
+
+ Set-Content -LiteralPath $reportPath -Value ($Candidate | ConvertTo-Json -Depth 30) -Encoding utf8NoBOM
+ if ($ErrorPattern) {
+ Assert-ThrowsLike -Pattern $ErrorPattern -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -ExpectedCompositionPath $compositionPath -AllowBoundedNormalization
+ }
+ }
+ else {
+ $accepted = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -ExpectedCompositionPath $compositionPath
+ Assert-True (-not $accepted.normalized) 'valid expected composition is accepted without repairs'
+ }
+ }
+
+ $genericMissingReason = $missingLeafReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $genericMissingReason.'outcome-reason' = 'Budget expired.'
+ Assert-CompositionReport $genericMissingReason '*SUPER_MISSING_LEAF_REASON*'
+ $genericMissingReason.'outcome-reason' = 'prefix-al-security-review-suffix was not evaluated.'
+ Assert-CompositionReport $genericMissingReason '*SUPER_MISSING_LEAF_REASON*'
+
+ foreach ($fabricatedOutcome in 'completed', 'not-applicable', 'no-knowledge') {
+ $fabricatedLeafReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $fabricatedLeafReport.'sub-results'[1].outcome = $fabricatedOutcome
+ if ($fabricatedOutcome -cne 'completed') {
+ $fabricatedLeafReport.'sub-results'[1].summary.coverage.'worklist-size' = 0
+ $fabricatedLeafReport.'sub-results'[1].summary.coverage.'items-evaluated' = 0
+ $fabricatedLeafReport.summary.coverage.'worklist-size' = 1
+ $fabricatedLeafReport.summary.coverage.'items-evaluated' = 1
+ }
+ Assert-CompositionReport $fabricatedLeafReport '*SUPER_LEAF_NOT_ACCEPTED*'
+ }
+ Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-CompositionReport $missingLeafReport '*SUPER_ACCEPTED_LEAF_MISSING*'
+ $alteredLeafReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $alteredLeafReport.'sub-results'[1].summary.coverage.'worklist-size' = 2
+ $alteredLeafReport.'sub-results'[1].summary.coverage.'items-evaluated' = 2
+ $alteredLeafReport.summary.coverage.'worklist-size' = 3
+ $alteredLeafReport.summary.coverage.'items-evaluated' = 3
+ Assert-CompositionReport $alteredLeafReport '*SUPER_LEAF_CONTENT_MISMATCH*'
+ $reorderedProperties = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $reorderedProperties.'sub-results'[0].skill = [pscustomobject]@{ version = 1; id = 'al-style-review' }
+ Assert-CompositionReport $reorderedProperties
+ foreach ($alteredOutcome in 'not-applicable', 'no-knowledge') {
+ $alteredOutcomeReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $alteredOutcomeReport.'sub-results'[1].outcome = $alteredOutcome
+ $alteredOutcomeReport.'sub-results'[1].summary.coverage.'worklist-size' = 0
+ $alteredOutcomeReport.'sub-results'[1].summary.coverage.'items-evaluated' = 0
+ $alteredOutcomeReport.summary.coverage.'worklist-size' = 1
+ $alteredOutcomeReport.summary.coverage.'items-evaluated' = 1
+ Assert-CompositionReport $alteredOutcomeReport '*SUPER_LEAF_CONTENT_MISMATCH*'
+ }
+ $relativeCaptureComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ foreach ($capture in $relativeCaptureComposition.acceptedResults) {
+ $capture.reportPath = Split-Path -Leaf $capture.reportPath
+ }
+ Set-Content -LiteralPath $compositionPath -Value ($relativeCaptureComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-CompositionReport $validSuperReport
+ $uncapturedComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $uncapturedComposition.PSObject.Properties.Remove('acceptedResults')
+ Set-Content -LiteralPath $compositionPath -Value ($uncapturedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-CompositionReport $validSuperReport '*Invalid expected composition*'
+ $duplicateCaptureComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $duplicateCaptureComposition.acceptedResults = @($duplicateCaptureComposition.acceptedResults[0], $duplicateCaptureComposition.acceptedResults[0])
+ Set-Content -LiteralPath $compositionPath -Value ($duplicateCaptureComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-CompositionReport $validSuperReport '*Invalid expected composition*'
+
+ $capturedFindingLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $capturedFindingLeaf.findings = @(@{
+ id = 'agent:leaf-issue'
+ severity = 'minor'
+ confidence = 'medium'
+ message = 'Preserve this accepted leaf finding.'
+ references = @()
+ })
+ $secondCapturedFinding = $capturedFindingLeaf.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $secondCapturedFinding.id = 'agent:second-leaf-issue'
+ $capturedFindingLeaf.findings = @($capturedFindingLeaf.findings[0], $secondCapturedFinding)
+ $capturedFindingLeaf.summary.counts.minor = 2
+ $findingComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $findingComposition.acceptedResults = @(Save-AcceptedLeafReports @($capturedFindingLeaf, $completedSecurityLeaf))
+ Set-Content -LiteralPath $compositionPath -Value ($findingComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $capturedFindingReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $capturedFindingReport.'sub-results'[0] = $capturedFindingLeaf
+ $capturedFindingReport.findings = @($capturedFindingLeaf.findings | ConvertTo-Json -Depth 20 | ConvertFrom-Json)
+ foreach ($finding in $capturedFindingReport.findings) {
+ $finding.id = "al-style-review:$($finding.id)"
+ $finding | Add-Member -NotePropertyName 'from-sub-skill' -NotePropertyValue 'al-style-review'
+ }
+ $capturedFindingReport.summary.counts.minor = 2
+ Assert-CompositionReport $capturedFindingReport
+ $reorderedFindingReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $reorderedFindingReport.'sub-results'[0].findings = @(
+ $reorderedFindingReport.'sub-results'[0].findings[1]
+ $reorderedFindingReport.'sub-results'[0].findings[0]
+ )
+ Assert-CompositionReport $reorderedFindingReport '*SUPER_LEAF_CONTENT_MISMATCH*'
+ $addedLeafFieldReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $addedLeafFieldReport.'sub-results'[0] | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'A composer-added field.'
+ Assert-CompositionReport $addedLeafFieldReport '*SUPER_LEAF_CONTENT_MISMATCH*'
+ $alteredFindingReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $alteredFindingReport.'sub-results'[0].findings[0].message = 'A fabricated replacement message.'
+ $alteredFindingReport.findings[0].message = 'A fabricated replacement message.'
+ Assert-CompositionReport $alteredFindingReport '*SUPER_LEAF_CONTENT_MISMATCH*'
+ $removedFindingReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $removedFindingReport.'sub-results'[0].findings = @()
+ $removedFindingReport.'sub-results'[0].summary.counts.minor = 0
+ $removedFindingReport.findings = @()
+ $removedFindingReport.summary.counts.minor = 0
+ Assert-CompositionReport $removedFindingReport '*SUPER_LEAF_CONTENT_MISMATCH*'
+
+ $capturedCorrectionLeaf = $capturedFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $capturedCorrectionLeaf.findings[0] | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'exit(1);'
+ $correctionComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $correctionComposition.acceptedResults = @(Save-AcceptedLeafReports @($capturedCorrectionLeaf, $completedSecurityLeaf))
+ Set-Content -LiteralPath $compositionPath -Value ($correctionComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $capturedCorrectionReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $capturedCorrectionReport.'sub-results'[0] = $capturedCorrectionLeaf
+ $capturedCorrectionReport.findings[0] | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'exit(1);'
+ Assert-CompositionReport $capturedCorrectionReport
+ $correctionCapturePath = $correctionComposition.acceptedResults[0].reportPath
+ $immutableCorrectionCapture = [IO.File]::ReadAllText($correctionCapturePath)
+ foreach ($codePoint in @(0x0000, 0x00AD, 0x200B, 0xFEFF)) {
+ $alteredCorrectionReport = $capturedCorrectionReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $alteredCorrection = 'ex' + [char]$codePoint + 'it(1);'
+ $alteredCorrectionReport.'sub-results'[0].findings[0].'suggested-code' = $alteredCorrection
+ $alteredCorrectionReport.findings[0].'suggested-code' = $alteredCorrection
+ Assert-CompositionReport $alteredCorrectionReport '*SUPER_LEAF_CONTENT_MISMATCH*'
+ $rolledOnlyCorrectionReport = $capturedCorrectionReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $rolledOnlyCorrectionReport.findings[0].'suggested-code' = $alteredCorrection
+ Assert-CompositionReport $rolledOnlyCorrectionReport '*SUPER_FINDING_MISMATCH*'
+ Assert-True ([string]::Equals([IO.File]::ReadAllText($correctionCapturePath), $immutableCorrectionCapture, [StringComparison]::Ordinal)) `
+ 'rejecting altered corrections leaves the immutable host capture unchanged'
+ }
+ $timestampReason = '2026-10-02T09:00:00Z'
+ $timestampLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $timestampLeaf | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue $timestampReason
+ $timestampComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $timestampComposition.acceptedResults = @(Save-AcceptedLeafReports @($timestampLeaf, $completedSecurityLeaf))
+ Set-Content -LiteralPath $compositionPath -Value ($timestampComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $timestampReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $timestampReport.'sub-results'[0] = $timestampLeaf
+ foreach ($alteredTimestamp in @('2026-10-02T09:00:00.000Z', '2026-10-02T09:00:00+00:00')) {
+ $timestampLeaf.'outcome-reason' = $alteredTimestamp
+ Assert-CompositionReport $timestampReport '*SUPER_LEAF_CONTENT_MISMATCH*'
+ }
+ $timestampLeaf.'outcome-reason' = $timestampReason
+ Set-Content -LiteralPath $reportPath -Value ($timestampReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $acceptedTimestampReport = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -ExpectedCompositionPath $compositionPath
+ $acceptedReason = $acceptedTimestampReport.report.'sub-results'[0].'outcome-reason'
+ Assert-True ($acceptedReason -is [string] -and [string]::Equals($acceptedReason, $timestampReason, [StringComparison]::Ordinal)) `
+ 'accepted timestamp-shaped JSON text remains the original literal string'
+ $normalizedTimestampReport = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $normalizedTimestampReport | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue '2026-10-02T09:00:00.000Z'
+ $normalizedTimestampReport.findings[0].location.range.'start-line' = 1
+ Set-Content -LiteralPath $reportPath -Value ($normalizedTimestampReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $acceptedNormalizedTimestamp = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp `
+ -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath -AllowBoundedNormalization
+ Assert-True $acceptedNormalizedTimestamp.normalized 'bounded normalization still applies to an eligible range'
+ Assert-True ($acceptedNormalizedTimestamp.report.'outcome-reason' -is [string] -and
+ [string]::Equals($acceptedNormalizedTimestamp.report.'outcome-reason', $normalizedTimestampReport.'outcome-reason', [StringComparison]::Ordinal)) `
+ 'bounded normalization preserves unrelated timestamp-shaped text exactly'
+ Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+
+ foreach ($case in @(
+ @{ Pattern = '*SUPER_IDENTITY_MISMATCH*'; Change = { param($candidate) $candidate.skill.id = 'al-other-review' } }
+ @{ Pattern = '*SUPER_IDENTITY_MISMATCH*'; Change = { param($candidate) $candidate.skill.version = 2 } }
+ @{ Pattern = '*SUPER_LEAF_VERSION_MISMATCH*'; Change = { param($candidate) $candidate.'sub-results'[0].skill.version = 2 } }
+ @{ Pattern = '*SUPER_UNEXPECTED_SUB_RESULT*'; Change = { param($candidate) $candidate.'sub-results'[0].skill.id = 'al-other-review' } }
+ @{ Pattern = '*SUPER_SUB_RESULT_ORDER*'; Change = { param($candidate) $candidate.'sub-results' = @($candidate.'sub-results'[1], $candidate.'sub-results'[0]) } }
+ @{ Pattern = '*SUPER_DUPLICATE_SUB_RESULT*'; Change = { param($candidate) $candidate.'sub-results' = @($candidate.'sub-results'[0], $candidate.'sub-results'[0]) } }
+ )) {
+ $candidate = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ & $case.Change $candidate
+ Assert-CompositionReport $candidate $case.Pattern
+ }
+
+ $fabricatedSkip = $missingLeafReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $fabricatedSkip | Add-Member -NotePropertyName 'skipped-sub-skills' -NotePropertyValue @(
+ @{ skill = @{ id = 'al-security-review'; version = 1 }; reason = 'configuration' }
+ )
+ Assert-CompositionReport $fabricatedSkip '*SUPER_UNEXPECTED_SKIP*'
+
+ $emptyAcceptedComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $emptyAcceptedComposition.acceptedResults = @()
+ Set-Content -LiteralPath $compositionPath -Value ($emptyAcceptedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $noResults = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $noResults.'sub-results' = @()
+ $noResults.summary.coverage.'worklist-size' = 0
+ $noResults.summary.coverage.'items-evaluated' = 0
+ $noResults.outcome = 'not-applicable'
+ Assert-CompositionReport $noResults '*SUPER_OUTCOME_MISMATCH*'
+ $noResults.outcome = 'failed'
+ $noResults | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'al-style-review and al-security-review could not be evaluated.'
+ Assert-CompositionReport $noResults
+
+ $oneMissingId = $noResults | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $oneMissingId.'outcome-reason' = 'al-security-review could not be evaluated.'
+ Assert-CompositionReport $oneMissingId '*SUPER_MISSING_LEAF_REASON*'
+ foreach ($baseReport in @($missingLeafReport, $noResults, $validSuperReport)) {
+ $capturedComposition = if ($baseReport.outcome -ceq 'completed') { $expectedComposition } else { $incompleteComposition }
+ Set-Content -LiteralPath $compositionPath -Value ($capturedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $selfReviewReport = $baseReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $selfReviewReport.findings = @(@{
+ id = 'agent:cross-domain-gap'
+ domain = 'Agent'
+ severity = 'minor'
+ confidence = 'medium'
+ message = 'A cross-domain issue needs attention.'
+ references = @()
+ 'from-sub-skill' = 'agent'
+ })
+ $selfReviewReport.summary.counts.minor = 1
+ if ($baseReport.outcome -ceq 'completed') {
+ Assert-CompositionReport $selfReviewReport
+ }
+ elseif ($baseReport.outcome -ceq 'failed') {
+ Assert-CompositionReport $selfReviewReport '*Invalid findings-report JSON or schema*'
+ }
+ else {
+ Assert-CompositionReport $selfReviewReport '*SUPER_AGENT_REVIEW_INCOMPLETE*'
+ }
+ }
+
+ $allFailed = $noResults | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $allFailed.'sub-results' = @($completedLeaf, $completedSecurityLeaf) | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ foreach ($leaf in $allFailed.'sub-results') {
+ $leaf.outcome = 'failed'
+ $leaf.summary.coverage.'items-evaluated' = 0
+ $leaf | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'Invocation failed.'
+ }
+ $failedComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $failedComposition.acceptedResults = @(Save-AcceptedLeafReports $allFailed.'sub-results')
+ Set-Content -LiteralPath $compositionPath -Value ($failedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-CompositionReport $allFailed
+
+ $skipComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $skipComposition.subSkills = @($skipComposition.subSkills[0])
+ $skipComposition.skipped = @(@{ id = 'al-security-review'; version = 1; reason = 'not-applicable' })
+ $skipComposition.acceptedResults = @($skipComposition.acceptedResults[0])
+ Set-Content -LiteralPath $compositionPath -Value ($skipComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $validSkippedReport = $fabricatedSkip | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $validSkippedReport.outcome = 'completed'
+ $validSkippedReport.PSObject.Properties.Remove('outcome-reason')
+ $validSkippedReport.'skipped-sub-skills'[0].reason = 'not-applicable'
+ Assert-CompositionReport $validSkippedReport
+ Assert-CompositionReport $missingLeafReport '*SUPER_SKIP_MISSING*'
+ $wrongSkip = $validSkippedReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $wrongSkip.'skipped-sub-skills'[0].reason = 'configuration'
+ Assert-CompositionReport $wrongSkip '*SUPER_SKIP_MISMATCH*'
+ $wrongSkip.'skipped-sub-skills'[0].reason = 'not-applicable'
+ $wrongSkip.'skipped-sub-skills'[0].skill.version = 2
+ Assert-CompositionReport $wrongSkip '*SUPER_SKIP_MISMATCH*'
+ $duplicateSkip = $validSkippedReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $duplicateSkip.'skipped-sub-skills' = @($duplicateSkip.'skipped-sub-skills'[0], $duplicateSkip.'skipped-sub-skills'[0])
+ Assert-CompositionReport $duplicateSkip '*SUPER_SKIP_CONFLICT*'
+ $returnedAndSkipped = $validSkippedReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $returnedAndSkipped.'skipped-sub-skills'[0].skill.id = 'al-style-review'
+ Assert-CompositionReport $returnedAndSkipped '*SUPER_SKIP_CONFLICT*'
+
+ $allSkippedComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $allSkippedComposition.skipped = @($allSkippedComposition.subSkills | ForEach-Object {
+ @{ id = $_.id; version = $_.version; reason = 'configuration' }
+ })
+ $allSkippedComposition.subSkills = @()
+ $allSkippedComposition.acceptedResults = @()
+ Set-Content -LiteralPath $compositionPath -Value ($allSkippedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $allSkippedReport = $noResults | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $allSkippedReport.outcome = 'not-applicable'
+ $allSkippedReport.PSObject.Properties.Remove('outcome-reason')
+ $allSkippedReport | Add-Member -NotePropertyName 'skipped-sub-skills' -NotePropertyValue @(
+ $allSkippedComposition.skipped | ForEach-Object { @{ skill = @{ id = $_.id; version = $_.version }; reason = $_.reason } }
+ )
+ Assert-CompositionReport $allSkippedReport
+
+ $invalidComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $invalidComposition.skipped = @(@{ id = 'al-style-review'; version = 1; reason = 'configuration' })
+ Set-Content -LiteralPath $compositionPath -Value ($invalidComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-CompositionReport $validSuperReport '*Invalid expected composition*'
+ $invalidComposition.skipped = @()
+ $invalidComposition.subSkills[0].version = '1'
+ Set-Content -LiteralPath $compositionPath -Value ($invalidComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-CompositionReport $validSuperReport '*Invalid expected composition*'
+ Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+
+ $layerFixtureRoot = Join-Path $tmp 'layered-skills'
+ foreach ($layer in 'microsoft', 'community', 'custom') {
+ $layerDirectory = Join-Path $layerFixtureRoot $layer
+ New-Item -ItemType Directory -Path $layerDirectory -Force | Out-Null
+ $sourceSkills = Join-Path $Root "$layer/skills"
+ if (Test-Path -LiteralPath $sourceSkills -PathType Container) {
+ Copy-Item -LiteralPath $sourceSkills -Destination (Join-Path $layerDirectory 'skills') -Recurse
+ }
+ }
+ $customSkillDirectory = Join-Path $layerFixtureRoot 'custom/skills/review'
+ New-Item -ItemType Directory -Path $customSkillDirectory -Force | Out-Null
+ $customSkillPath = 'custom/skills/review/company-style-review.md'
+ $styleSkillText = Get-Content -LiteralPath (Join-Path $Root 'microsoft/skills/review/al-style-review.md') -Raw
+ Set-Content -LiteralPath (Join-Path $layerFixtureRoot $customSkillPath) `
+ -Value ($styleSkillText -replace '(?m)^version: 1\r?$', 'version: 7') -Encoding utf8NoBOM
+ $fixtureIndexPath = Join-Path $tmp 'layered-skill-index.json'
+ & (Join-Path $Root 'tools/Build-SkillIndex.ps1') -BCQualityRoot $layerFixtureRoot -IndexPath $fixtureIndexPath | Out-Null
+ $fixtureIndex = Get-Content -LiteralPath $fixtureIndexPath -Raw | ConvertFrom-Json
+ foreach ($selection in @(
+ @{ Disabled = @(); ExpectedLayer = 'custom'; ExpectedVersion = 7 }
+ @{ Disabled = @($customSkillPath); ExpectedLayer = 'microsoft'; ExpectedVersion = 1 }
+ @{ Disabled = @($customSkillPath, 'microsoft/skills/review/al-style-review.md'); ExpectedLayer = $null }
+ )) {
+ $resolved = & (Join-Path $Root 'tools/Resolve-SkillWorklist.ps1') -BCQualityRoot $layerFixtureRoot `
+ -IndexPath $fixtureIndexPath -SuperSkillPath 'microsoft/skills/review/al-code-review.md' `
+ -DisabledSkills $selection.Disabled
+ $styleSlots = @($resolved.subSkills | Where-Object id -CEQ 'al-style-review')
+ if ($selection.ExpectedLayer) {
+ Assert-True ($styleSlots.Count -eq 1 -and $styleSlots[0].layer -ceq $selection.ExpectedLayer -and
+ $styleSlots[0].version -eq $selection.ExpectedVersion) 'resolver-selected override or fallback is authoritative'
+ }
+ else {
+ Assert-True ($styleSlots.Count -eq 0) 'fully disabled slot is not selected'
+ }
+ $resolved.skipped = @($resolved.skipped | ForEach-Object {
+ $declaredPath = $_.declaredPath
+ $declaredSkill = @($fixtureIndex.skills | Where-Object path -CEQ $declaredPath)[0]
+ @{ id = $_.id; version = $declaredSkill.version; reason = $_.reason; declaredPath = $declaredPath }
+ })
+ $resolvedReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $resolvedReport.'sub-results' = @($resolved.subSkills | ForEach-Object {
+ $leafReport = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $leafReport.skill.id = $_.id
+ $leafReport.skill.version = $_.version
+ $leafReport
+ })
+ $resolvedReport.summary.coverage.'worklist-size' = $resolved.subSkills.Count
+ $resolvedReport.summary.coverage.'items-evaluated' = $resolved.subSkills.Count
+ $resolvedReport | Add-Member -NotePropertyName 'skipped-sub-skills' -NotePropertyValue @(
+ $resolved.skipped | ForEach-Object { @{ skill = @{ id = $_.id; version = $_.version }; reason = $_.reason } }
+ )
+ $resolved | Add-Member -NotePropertyName 'acceptedResults' -NotePropertyValue @(Save-AcceptedLeafReports $resolvedReport.'sub-results')
+ Set-Content -LiteralPath $compositionPath -Value ($resolved | ConvertTo-Json -Depth 30) -Encoding utf8NoBOM
+ Assert-CompositionReport $resolvedReport
+ }
+ Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+
+ $styleFindingLeaf = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $securityFindingLeaf = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $securityFindingLeaf.skill.id = 'al-security-review'
+ $rolledFinding = $validReport.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $rolledFinding | Add-Member -NotePropertyName 'from-sub-skill' -NotePropertyValue 'al-style-review'
+ $deduplicatedSuperReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $deduplicatedSuperReport.summary.counts.minor = 1
+ $deduplicatedSuperReport.findings = @($rolledFinding)
+ $deduplicatedSuperReport.'sub-results' = @($styleFindingLeaf, $securityFindingLeaf)
+ Set-Content -LiteralPath $reportPath -Value ($deduplicatedSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $acceptedDeduplicatedSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath
+ Assert-True (-not $acceptedDeduplicatedSuper.normalized) 'one top-level finding may deduplicate the same citation from two leaves'
+
+ $twoOccurrenceLeaf = $styleFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $secondOccurrence = $twoOccurrenceLeaf.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $secondOccurrence.location.line = 1
+ $secondOccurrence.location.range.'start-line' = 1
+ $secondOccurrence.location.range.'end-line' = 1
+ $twoOccurrenceLeaf.findings = @($twoOccurrenceLeaf.findings[0], $secondOccurrence)
+ $twoOccurrenceLeaf.summary.counts.minor = 2
+ $emptySecurityLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $emptySecurityLeaf.skill.id = 'al-security-review'
+ $sameIdOccurrenceOmitted = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $sameIdOccurrenceOmitted.'sub-results' = @($twoOccurrenceLeaf, $emptySecurityLeaf)
+ Set-Content -LiteralPath $reportPath -Value ($sameIdOccurrenceOmitted | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISSING*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath
+ }
+
+ $mergeOwnerLeaf = $styleFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $mergeOwnerLeaf.findings[0] | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'Caption = ''Customer name'';'
+ $supportingFindingLeaf = $securityFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $supportingFindingLeaf.findings[0].id = $supportingArticlePath
+ $supportingFindingLeaf.findings[0].references[0].path = $supportingArticlePath
+ $supportingFindingLeaf.findings[0].confidence = 'medium'
+ $supportingFindingLeaf.findings[0].message = 'The field needs the same mechanical correction for a supporting rule.'
+ $supportingFindingLeaf.findings[0] | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'Caption = ''Customer name'';'
+ $mergedFinding = $rolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $mergedFinding | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'Caption = ''Customer name'';'
+ $mergedFinding.references = @(
+ [pscustomobject]@{ path = $articlePath }
+ [pscustomobject]@{ path = $supportingArticlePath }
+ )
+ $mergedSuperReport = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $mergedSuperReport.findings = @($mergedFinding)
+ $mergedSuperReport.'sub-results' = @($mergeOwnerLeaf, $supportingFindingLeaf)
+ Set-Content -LiteralPath $reportPath -Value ($mergedSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $acceptedMergedSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath
+ Assert-True (-not $acceptedMergedSuper.normalized) 'overlapping A and B findings may merge into A with B as a supporting reference'
+
+ $textOnlyOwnerLeaf = $mergeOwnerLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $textOnlyOwnerLeaf.findings[0].PSObject.Properties.Remove('suggested-code')
+ $textOnlySupportingLeaf = $supportingFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $textOnlySupportingLeaf.findings[0].PSObject.Properties.Remove('suggested-code')
+ $textOnlyMergedFinding = $mergedFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $textOnlyMergedFinding.PSObject.Properties.Remove('suggested-code')
+ $textOnlyMergedSuper = $mergedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $textOnlyMergedSuper.findings = @($textOnlyMergedFinding)
+ $textOnlyMergedSuper.'sub-results' = @($textOnlyOwnerLeaf, $textOnlySupportingLeaf)
+ Set-Content -LiteralPath $reportPath -Value ($textOnlyMergedSuper | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $acceptedTextOnlyMerge = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath
+ Assert-True (-not $acceptedTextOnlyMerge.normalized) 'supporting references permit an overlapping A and B merge with different messages and no suggested code'
+
+ $conflictingSupportingLeaf = $supportingFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $conflictingSupportingLeaf.findings[0].'suggested-code' = 'ToolTip = ''Customer name'';'
+ $conflictingCorrectionMerge = $mergedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $conflictingCorrectionMerge.'sub-results' = @($mergeOwnerLeaf, $conflictingSupportingLeaf)
+ Set-Content -LiteralPath $reportPath -Value ($conflictingCorrectionMerge | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISSING*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath
+ }
+
+ $omittedConflictingCorrectionMerge = $conflictingCorrectionMerge | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $omittedConflictingCorrectionMerge.findings[0].PSObject.Properties.Remove('suggested-code')
+ Set-Content -LiteralPath $reportPath -Value ($omittedConflictingCorrectionMerge | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISMATCH*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath
+ }
+
+ $unmergedSupportingFinding = $supportingFindingLeaf.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $unmergedSupportingFinding | Add-Member -NotePropertyName 'from-sub-skill' -NotePropertyValue 'al-security-review'
+ $unmergedDuplicates = $mergedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $unmergedDuplicates.summary.counts.minor = 2
+ $unmergedDuplicates.findings = @($mergedFinding, $unmergedSupportingFinding)
+ Set-Content -LiteralPath $reportPath -Value ($unmergedDuplicates | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*SUPER_DUPLICATE_FINDINGS*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath
+ }
+
+ $omittedLeafFinding = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $omittedLeafFinding.summary.counts.minor = 0
+ $omittedLeafFinding.findings = @()
+ Set-Content -LiteralPath $reportPath -Value ($omittedLeafFinding | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISSING*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath
+ }
+
+ $locationlessLeaf = $styleFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $locationlessLeaf.findings[0].PSObject.Properties.Remove('location')
+ $secondLocationlessFinding = $locationlessLeaf.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $locationlessLeaf.findings = @($locationlessLeaf.findings[0], $secondLocationlessFinding)
+ $locationlessLeaf.summary.counts.minor = 2
+ $locationlessRolledFinding = $rolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $locationlessRolledFinding.PSObject.Properties.Remove('location')
+ $locationlessOmission = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $locationlessOmission.findings = @($locationlessRolledFinding)
+ $locationlessOmission.'sub-results' = @($locationlessLeaf, $emptySecurityLeaf)
+ Set-Content -LiteralPath $reportPath -Value ($locationlessOmission | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISSING*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath
+ }
+
+ $completeLocationlessRollup = $locationlessOmission | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $completeLocationlessRollup.summary.counts.minor = 2
+ $completeLocationlessRollup.findings = @(
+ $locationlessRolledFinding
+ ($locationlessRolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json)
+ )
+ Set-Content -LiteralPath $reportPath -Value ($completeLocationlessRollup | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $acceptedLocationlessRollup = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath
+ Assert-True (-not $acceptedLocationlessRollup.normalized) 'two locationless leaf occurrences require and accept two distinct rolled findings'
+
+ $nonexistentProducer = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $nonexistentProducer.findings[0].'from-sub-skill' = 'al-missing-review'
+ Set-Content -LiteralPath $reportPath -Value ($nonexistentProducer | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*SUPER_PRODUCER_INVALID*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath
+ }
+
+ $rewrittenLeafFinding = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $rewrittenLeafFinding.findings[0].message = 'A rewritten rollup message.'
+ Set-Content -LiteralPath $reportPath -Value ($rewrittenLeafFinding | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISMATCH*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath
+ }
+
+ $failedLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $failedLeaf.skill.id = 'al-security-review'
+ $failedLeaf.outcome = 'failed'
+ $failedLeaf | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'Validation failed.'
+ $failedLeaf.summary.coverage.'items-evaluated' = 0
+ $partialSuperReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $partialSuperReport.outcome = 'partial'
+ $partialSuperReport | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'One sub-skill failed.'
+ $partialSuperReport.summary.coverage.'worklist-size' = 1
+ $partialSuperReport.summary.coverage.'items-evaluated' = 1
+ $partialSuperReport.'sub-results' = @($completedLeaf, $failedLeaf)
+ Set-Content -LiteralPath $reportPath -Value ($partialSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ $acceptedPartialSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super
+ Assert-True (-not $acceptedPartialSuper.normalized) 'partial super-skill excludes failed coverage from its rollup'
+ $partialComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $partialComposition.acceptedResults = @(Save-AcceptedLeafReports @($completedLeaf, $failedLeaf))
+ Set-Content -LiteralPath $compositionPath -Value ($partialComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-CompositionReport $partialSuperReport
+
+ $failedLeafLeakage = $partialSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $failedLeafLeakage.summary.counts.minor = 1
+ $failedLeafLeakage.findings = @($rolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json)
+ $failedLeafLeakage.findings[0].'from-sub-skill' = 'al-security-review'
+ Set-Content -LiteralPath $reportPath -Value ($failedLeafLeakage | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*SUPER_FAILED_FINDING_LEAKAGE*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath
+ }
+
+ $failedLeafRelabeledAsAgent = $partialSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $failedLeafRelabeledAsAgent.summary.counts.minor = 1
+ $failedLeafRelabeledAsAgent.findings = @($rolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json)
+ $failedLeafRelabeledAsAgent.findings[0].'from-sub-skill' = 'agent'
+ $failedLeafRelabeledAsAgent.findings[0].domain = 'Agent'
+ Set-Content -LiteralPath $reportPath -Value ($failedLeafRelabeledAsAgent | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*SUPER_AGENT_FINDING_INVALID*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
+ -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath
+ }
+
+ $incorrectOutcome = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $incorrectOutcome.outcome = 'not-applicable'
+ Set-Content -LiteralPath $reportPath -Value ($incorrectOutcome | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*SUPER_OUTCOME_MISMATCH*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super
+ }
+
+ $incorrectRollup = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $incorrectRollup.summary.coverage.'worklist-size' = 1
+ $incorrectRollup.summary.coverage.'items-evaluated' = 1
+ Set-Content -LiteralPath $reportPath -Value ($incorrectRollup | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*SUPER_COVERAGE_MISMATCH*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super
+ }
+
+ Set-Content -LiteralPath $reportPath -Value ($validReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*REFERENCE_NOT_RETRIEVED*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp -SourcePaths $sourcePath
+ }
+
+ $invalidAgent = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $invalidAgent.findings[0].id = 'agent:uncited-defect'
+ $invalidAgent.findings[0].references = @()
+ $invalidAgent.findings[0].confidence = 'high'
+ Set-Content -LiteralPath $reportPath -Value ($invalidAgent | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*AGENT_CONFIDENCE_INVALID*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp -SourcePaths $sourcePath
+ }
+
+ $normalizable = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ $normalizable.findings[0].location.range.'start-line' = 1
+ Set-Content -LiteralPath $reportPath -Value ($normalizable | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
+ Assert-ThrowsLike -Pattern '*RANGE_START_MISMATCH*' -Action {
+ & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp `
+ -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath
+ }
+ $normalized = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp `
+ -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath -AllowBoundedNormalization
+ Assert-True $normalized.normalized 'eligible range mismatch is normalized'
+ Assert-True ($normalized.removedRanges.Count -eq 1) 'normalization records one removed range'
+ Assert-True (-not ($normalized.report.findings[0].location.PSObject.Properties.Name -contains 'range')) `
+ 'accepted normalized report removes only the optional range'
+}
+finally {
+ Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+Write-Output "Review contract validation passed ($($cases.Count) predicate cases plus executable acceptance cases)."
diff --git a/tools/Test-ReviewFixtures.ps1 b/tools/Test-ReviewFixtures.ps1
index a9912b7..f9be512 100644
--- a/tools/Test-ReviewFixtures.ps1
+++ b/tools/Test-ReviewFixtures.ps1
@@ -18,7 +18,9 @@ param(
[string] $ManifestPath,
[string] $PrepareDirectory,
[string] $ResultsPath,
- [string] $ResultsDirectory
+ [string] $ResultsDirectory,
+ [string] $ChangedPathsFile,
+ [string] $CoverageReportPath
)
Set-StrictMode -Version Latest
@@ -160,7 +162,23 @@ foreach ($overrideDomain in $overrides.Keys) {
}
}
+$coverageWaivers = @{}
+if ($manifest.PSObject.Properties.Name -contains 'coverageWaivers') {
+ foreach ($waiver in @($manifest.coverageWaivers)) {
+ if (-not $waiver.path -or -not $waiver.reason) {
+ $problems.Add('Each coverage waiver requires non-empty path and reason values.') | Out-Null
+ continue
+ }
+ if ($coverageWaivers.ContainsKey([string]$waiver.path)) {
+ $problems.Add("Duplicate coverage waiver: $($waiver.path)") | Out-Null
+ continue
+ }
+ $coverageWaivers[[string]$waiver.path] = [string]$waiver.reason
+ }
+}
+
$caseList = [System.Collections.Generic.List[object]]::new()
+$pairedArticlesByDomain = @{}
foreach ($domain in $leafDomains) {
$articleCandidates = @(
foreach ($layer in $layers) {
@@ -189,18 +207,49 @@ foreach ($domain in $leafDomains) {
ForEach-Object { $_.Group | Sort-Object Rank -Descending | Select-Object -First 1 } |
Sort-Object BaseName
)
+ $pairedArticlesByDomain[$domain] = @($articles)
if (-not $articles.Count) {
$problems.Add("${domain}: no enabled knowledge layer has an article with both .good.al and .bad.al companion samples.") | Out-Null
continue
}
$override = if ($overrides.ContainsKey($domain)) { $overrides[$domain] } else { $null }
- $selectedArticle = $null
- if ($override -and ($override.PSObject.Properties.Name -contains 'article')) {
- $articleName = [string]$override.article
+ $hasArticleOverride = $override -and ($override.PSObject.Properties.Name -contains 'article')
+ $hasArticlesOverride = $override -and ($override.PSObject.Properties.Name -contains 'articles')
+ if ($hasArticleOverride -and $hasArticlesOverride) {
+ $problems.Add("${domain}: override must specify either 'article' or 'articles', not both.") | Out-Null
+ continue
+ }
+
+ $articleNames = @()
+ if ($hasArticlesOverride) {
+ $articleNames = @($override.articles)
+ if (-not $articleNames.Count) {
+ $problems.Add("${domain}: override 'articles' must contain at least one article.") | Out-Null
+ continue
+ }
+ } elseif ($hasArticleOverride) {
+ $articleNames = @($override.article)
+ } else {
+ $articleNames = @($articles | Select-Object -First 1 | ForEach-Object BaseName)
+ }
+
+ $selectedArticles = [System.Collections.Generic.List[object]]::new()
+ $seenArticleNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($articleNameValue in $articleNames) {
+ if ($articleNameValue -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$articleNameValue)) {
+ $problems.Add("${domain}: override article names must be non-empty strings.") | Out-Null
+ continue
+ }
+ $articleName = [string]$articleNameValue
if ($articleName.EndsWith('.md')) {
$articleName = [System.IO.Path]::GetFileNameWithoutExtension($articleName)
}
+ if (-not $seenArticleNames.Add($articleName)) {
+ $problems.Add("${domain}: override contains duplicate article: $articleName.md") | Out-Null
+ continue
+ }
+
$selectedArticle = $articles | Where-Object BaseName -eq $articleName | Select-Object -First 1
if (-not $selectedArticle) {
$articleExists = @(
@@ -218,33 +267,43 @@ foreach ($domain in $leafDomains) {
}
continue
}
- } else {
- $selectedArticle = $articles | Select-Object -First 1
+ $selectedArticles.Add($selectedArticle) | Out-Null
}
- if (-not $selectedArticle) {
- $problems.Add("${domain}: no article has both .good.al and .bad.al companion samples.") | Out-Null
+ if (-not $selectedArticles.Count) {
+ if (-not $articleNames.Count) {
+ $problems.Add("${domain}: no article has both .good.al and .bad.al companion samples.") | Out-Null
+ }
continue
}
- $articlePath = [string]$selectedArticle.ArticlePath
- $sampleDirectory = (Split-Path -Parent $articlePath).Replace('\', '/')
$context = if ($override -and ($override.PSObject.Properties.Name -contains 'context')) {
[string]$override.context
} else {
$null
}
- foreach ($kind in 'bad', 'good') {
- $case = [pscustomobject]@{
- id = "$domain-$kind"
- domain = $domain
- input = "$sampleDirectory/$($selectedArticle.BaseName).$kind.al"
- expected = if ($kind -eq 'bad') { @($articlePath) } else { @() }
+ for ($articleIndex = 0; $articleIndex -lt $selectedArticles.Count; $articleIndex++) {
+ $selectedArticle = $selectedArticles[$articleIndex]
+ $articlePath = [string]$selectedArticle.ArticlePath
+ $sampleDirectory = (Split-Path -Parent $articlePath).Replace('\', '/')
+ foreach ($kind in 'bad', 'good') {
+ $caseId = if ($articleIndex -eq 0) {
+ "$domain-$kind"
+ } else {
+ "$domain-$($selectedArticle.BaseName)-$kind"
+ }
+ $case = [pscustomobject]@{
+ id = $caseId
+ domain = $domain
+ input = "$sampleDirectory/$($selectedArticle.BaseName).$kind.al"
+ expected = if ($kind -eq 'bad') { @($articlePath) } else { @() }
+ }
+ if ($context) {
+ $case | Add-Member -NotePropertyName context -NotePropertyValue $context
+ }
+ $caseList.Add($case) | Out-Null
}
- if ($context) {
- $case | Add-Member -NotePropertyName context -NotePropertyValue $context
- }
- $caseList.Add($case) | Out-Null
}
+
}
$cases = @($caseList)
@@ -300,6 +359,65 @@ foreach ($domain in $leafDomains) {
}
}
+$selectedArticlePaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+foreach ($case in $cases) {
+ foreach ($reference in @($case.expected)) {
+ $selectedArticlePaths.Add([string]$reference) | Out-Null
+ }
+}
+$effectivePairedPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+$coverageDomains = @(
+ foreach ($domain in $leafDomains) {
+ $paired = @($pairedArticlesByDomain[$domain])
+ foreach ($article in $paired) {
+ $effectivePairedPaths.Add([string]$article.ArticlePath) | Out-Null
+ }
+ $selected = @($paired | Where-Object { $selectedArticlePaths.Contains([string]$_.ArticlePath) }).Count
+ [pscustomobject][ordered]@{
+ domain = $domain
+ pairedArticles = $paired.Count
+ selectedArticles = $selected
+ coverage = if ($paired.Count) { $selected / $paired.Count } else { 0 }
+ }
+ }
+)
+$pairedTotal = ($coverageDomains | Measure-Object pairedArticles -Sum).Sum
+$selectedTotal = ($coverageDomains | Measure-Object selectedArticles -Sum).Sum
+$coverageReport = [pscustomobject][ordered]@{
+ pairedArticles = $pairedTotal
+ selectedArticles = $selectedTotal
+ coverage = if ($pairedTotal) { $selectedTotal / $pairedTotal } else { 0 }
+ domains = $coverageDomains
+}
+if ($CoverageReportPath) {
+ $coverageParent = Split-Path -Parent $CoverageReportPath
+ if ($coverageParent -and -not (Test-Path -LiteralPath $coverageParent)) {
+ New-Item -ItemType Directory -Path $coverageParent -Force | Out-Null
+ }
+ $coverageReport | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $CoverageReportPath -Encoding utf8NoBOM
+}
+
+if ($ChangedPathsFile) {
+ if (-not (Test-Path -LiteralPath $ChangedPathsFile -PathType Leaf)) {
+ $problems.Add("Changed paths file not found: $ChangedPathsFile") | Out-Null
+ }
+ else {
+ foreach ($changedPathValue in Get-Content -LiteralPath $ChangedPathsFile) {
+ $changedPath = ([string]$changedPathValue).Trim().Replace('\', '/')
+ if ($changedPath -notmatch '^(microsoft|community|custom)/knowledge/[^/]+/(.+?)(?:\.(?:good|bad)\.al|\.md)$') {
+ continue
+ }
+ $articlePath = "$($Matches[1])/knowledge/$($changedPath.Split('/')[2])/$($Matches[2]).md"
+ if (-not $effectivePairedPaths.Contains($articlePath) -or $selectedArticlePaths.Contains($articlePath)) {
+ continue
+ }
+ if (-not $coverageWaivers.ContainsKey($articlePath)) {
+ $problems.Add("Changed paired article is not selected for evaluation and has no coverage waiver: $articlePath") | Out-Null
+ }
+ }
+ }
+}
+
if ($problems.Count) {
Write-Host "Review fixture validation FAILED ($($problems.Count) problem(s)):" -ForegroundColor Red
$problems | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
@@ -434,7 +552,8 @@ if ($PrepareDirectory) {
}
if (-not $ResultsPath -and -not $ResultsDirectory) {
- Write-Host "Review fixture validation PASSED: $($cases.Count) cases cover $($leafDomains.Count) leaf domains." -ForegroundColor Green
+ & (Join-Path $PSScriptRoot 'Test-ReviewContract.ps1') -Root $Root
+ Write-Host "Review fixture validation PASSED: $($cases.Count) cases cover $selectedTotal/$pairedTotal paired articles across $($leafDomains.Count) leaf domains." -ForegroundColor Green
exit 0
}
diff --git a/tools/Validate-FindingsReport.ps1 b/tools/Validate-FindingsReport.ps1
new file mode 100644
index 0000000..b3c41b3
--- /dev/null
+++ b/tools/Validate-FindingsReport.ps1
@@ -0,0 +1,768 @@
+#Requires -Version 7.5
+<#
+.SYNOPSIS
+ Validates a BCQuality findings-report against its structural and semantic contract.
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)]
+ [string] $ReportPath,
+ [string] $BCQualityRoot,
+ [string] $SourceRoot,
+ [string[]] $SourcePaths = @(),
+ [string[]] $RetrievedArticlePaths = @(),
+ [ValidateSet('leaf', 'super')]
+ [string] $SkillKind = 'leaf',
+ [string] $ExpectedCompositionPath,
+ [switch] $AllowBoundedNormalization
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+if (-not $BCQualityRoot) {
+ $BCQualityRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+}
+$BCQualityRoot = (Resolve-Path -LiteralPath $BCQualityRoot).Path
+$schemaPath = Join-Path $BCQualityRoot 'schemas/findings-report.schema.json'
+$raw = Get-Content -LiteralPath $ReportPath -Raw
+try {
+ if (-not ($raw | Test-Json -SchemaFile $schemaPath -ErrorAction Stop)) {
+ throw 'Report does not satisfy schemas/findings-report.schema.json.'
+ }
+ $report = $raw | ConvertFrom-Json -Depth 100 -DateKind String
+}
+catch {
+ throw "Invalid findings-report JSON or schema: $($_.Exception.Message)"
+}
+
+$expectedComposition = $null
+$expectedLeaves = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
+$expectedSkips = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
+$acceptedLeaves = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
+if ($ExpectedCompositionPath) {
+ if ($SkillKind -cne 'super') {
+ throw 'Expected composition is supported only for super-skill reports.'
+ }
+ $contractSchema = Get-Content -LiteralPath $schemaPath -Raw | ConvertFrom-Json -AsHashtable
+ $identitySchema = @{
+ type = 'object'
+ required = @('id', 'version')
+ properties = $contractSchema.definitions.skillReference.properties
+ }
+ $skipProperties = @{
+ id = $identitySchema.properties.id
+ version = $identitySchema.properties.version
+ reason = @{ enum = @('configuration', 'not-applicable') }
+ }
+ $compositionSchema = @{
+ type = 'object'
+ required = @('superSkill', 'subSkills', 'skipped', 'acceptedResults')
+ properties = @{
+ superSkill = $identitySchema
+ subSkills = @{ type = 'array'; items = $identitySchema }
+ acceptedResults = @{
+ type = 'array'
+ items = @{
+ type = 'object'
+ required = @('id', 'version', 'reportPath')
+ properties = @{
+ id = $identitySchema.properties.id
+ version = $identitySchema.properties.version
+ reportPath = @{ type = 'string'; minLength = 1 }
+ }
+ }
+ }
+ skipped = @{
+ type = 'array'
+ items = @{ type = 'object'; required = @('id', 'version', 'reason'); properties = $skipProperties }
+ }
+ }
+ } | ConvertTo-Json -Depth 20
+ try {
+ $compositionRaw = Get-Content -LiteralPath $ExpectedCompositionPath -Raw
+ if (-not ($compositionRaw | Test-Json -Schema $compositionSchema -ErrorAction Stop)) {
+ throw 'Expected composition does not satisfy its input contract.'
+ }
+ $expectedComposition = $compositionRaw | ConvertFrom-Json -Depth 100 -DateKind String
+ $expectedIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ foreach ($leaf in @($expectedComposition.subSkills)) {
+ if (-not $expectedIds.Add([string]$leaf.id)) {
+ throw "Duplicate expected skill id '$($leaf.id)'."
+ }
+ $expectedLeaves.Add([string]$leaf.id, $leaf)
+ }
+ foreach ($skip in @($expectedComposition.skipped)) {
+ if (-not $expectedIds.Add([string]$skip.id)) {
+ throw "Duplicate or selected-and-skipped expected skill id '$($skip.id)'."
+ }
+ $expectedSkips.Add([string]$skip.id, $skip)
+ }
+ $compositionDirectory = Split-Path -Parent (Resolve-Path -LiteralPath $ExpectedCompositionPath).Path
+ foreach ($accepted in @($expectedComposition.acceptedResults)) {
+ if (-not $expectedLeaves.ContainsKey([string]$accepted.id) -or
+ $accepted.version -ne $expectedLeaves[$accepted.id].version -or
+ $acceptedLeaves.ContainsKey([string]$accepted.id)) {
+ throw "Accepted result '$($accepted.id)' must uniquely match a selected leaf and version."
+ }
+ $acceptedPath = if ([IO.Path]::IsPathRooted($accepted.reportPath)) {
+ $accepted.reportPath
+ }
+ else {
+ Join-Path $compositionDirectory $accepted.reportPath
+ }
+ $acceptedRaw = Get-Content -LiteralPath $acceptedPath -Raw
+ if (-not ($acceptedRaw | Test-Json -SchemaFile $schemaPath -ErrorAction Stop)) {
+ throw "Accepted result '$($accepted.id)' does not satisfy the report schema."
+ }
+ $acceptedReport = $acceptedRaw | ConvertFrom-Json -Depth 100 -DateKind String
+ if ($acceptedReport.skill.id -cne $accepted.id -or $acceptedReport.skill.version -ne $accepted.version -or
+ $acceptedReport.PSObject.Properties.Name -ccontains 'sub-results' -or
+ $acceptedReport.PSObject.Properties.Name -ccontains 'skipped-sub-skills') {
+ throw "Accepted result '$($accepted.id)' must be a leaf report with the captured identity."
+ }
+ $acceptedLeaves.Add([string]$accepted.id, $acceptedReport)
+ }
+ }
+ catch {
+ throw "Invalid expected composition: $($_.Exception.Message)"
+ }
+}
+
+$retrieved = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+foreach ($path in $RetrievedArticlePaths) {
+ $retrieved.Add($path) | Out-Null
+}
+$sources = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+foreach ($path in $SourcePaths) {
+ $sources.Add($path) | Out-Null
+}
+$lineCounts = @{}
+
+function Test-HasProperty {
+ param([object] $Object, [string] $Name)
+ return $null -ne $Object -and $Object.PSObject.Properties.Name -ccontains $Name
+}
+
+function Test-JsonContentEqual {
+ param([object] $First, [object] $Second)
+
+ if ($null -eq $First -or $null -eq $Second) {
+ return $null -eq $First -and $null -eq $Second
+ }
+ if ($First -is [pscustomobject] -or $Second -is [pscustomobject]) {
+ if ($First -isnot [pscustomobject] -or $Second -isnot [pscustomobject] -or
+ @($First.PSObject.Properties).Count -ne @($Second.PSObject.Properties).Count) {
+ return $false
+ }
+ foreach ($property in $First.PSObject.Properties) {
+ if (-not (Test-HasProperty $Second $property.Name) -or
+ -not (Test-JsonContentEqual $property.Value $Second.PSObject.Properties[$property.Name].Value)) {
+ return $false
+ }
+ }
+ return $true
+ }
+ if ($First -is [array] -or $Second -is [array]) {
+ if ($First -isnot [array] -or $Second -isnot [array] -or $First.Count -ne $Second.Count) {
+ return $false
+ }
+ for ($index = 0; $index -lt $First.Count; $index++) {
+ if (-not (Test-JsonContentEqual $First[$index] $Second[$index])) {
+ return $false
+ }
+ }
+ return $true
+ }
+ if ($First -is [string] -or $Second -is [string]) {
+ return $First -is [string] -and $Second -is [string] -and
+ [string]::Equals($First, $Second, [StringComparison]::Ordinal)
+ }
+ return $First.GetType() -eq $Second.GetType() -and $First -ceq $Second
+}
+
+function Get-SourceLineCount {
+ param([string] $Path)
+
+ if ($lineCounts.ContainsKey($Path)) {
+ return $lineCounts[$Path]
+ }
+ if (-not $SourceRoot) {
+ return -1
+ }
+ $fullPath = Join-Path $SourceRoot ($Path -replace '/', [IO.Path]::DirectorySeparatorChar)
+ if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
+ return -1
+ }
+ $lineCounts[$Path] = [IO.File]::ReadAllLines($fullPath).Count
+ return $lineCounts[$Path]
+}
+
+function Get-SemanticErrors {
+ param(
+ [object] $Candidate,
+ [switch] $PermitRangeStartMismatch
+ )
+
+ $errors = [Collections.Generic.List[object]]::new()
+ function Add-Error {
+ param([string] $Code, [string] $Path, [string] $Message)
+ $errors.Add([pscustomobject]@{ Code = $Code; Path = $Path; Message = $Message }) | Out-Null
+ }
+
+ function Get-DerivedSuperOutcome {
+ param([object[]] $SubResults, [int] $MissingResults = 0)
+
+ if ($MissingResults -gt 0) {
+ if (@($SubResults | Where-Object outcome -CNE 'failed').Count) {
+ return 'partial'
+ }
+ return 'failed'
+ }
+ if (-not $SubResults.Count) {
+ return 'not-applicable'
+ }
+
+ $outcomes = @($SubResults | ForEach-Object { $_.outcome })
+ if (-not @($outcomes | Where-Object { $_ -cne 'failed' }).Count) {
+ return 'failed'
+ }
+ if (($outcomes -ccontains 'partial') -or
+ (($outcomes -ccontains 'failed') -and @($outcomes | Where-Object { $_ -cne 'failed' }).Count)) {
+ return 'partial'
+ }
+ if (-not @($outcomes | Where-Object { $_ -cne 'not-applicable' }).Count) {
+ return 'not-applicable'
+ }
+ if (($outcomes -ccontains 'no-knowledge') -and
+ -not @($outcomes | Where-Object { $_ -cnotin @('no-knowledge', 'not-applicable') }).Count) {
+ return 'no-knowledge'
+ }
+ return 'completed'
+ }
+
+ function Get-SeverityRank {
+ param([string] $Severity)
+ return @{'info' = 0; 'minor' = 1; 'major' = 2; 'blocker' = 3}[$Severity]
+ }
+
+ function Get-ConfidenceRank {
+ param([string] $Confidence)
+ return @{'low' = 0; 'medium' = 1; 'high' = 2}[$Confidence]
+ }
+
+ function Test-LocationsOverlap {
+ param([object] $First, [object] $Second)
+
+ $firstHasLocation = Test-HasProperty $First 'location'
+ $secondHasLocation = Test-HasProperty $Second 'location'
+ if ($firstHasLocation -ne $secondHasLocation) {
+ return $false
+ }
+ if (-not $firstHasLocation) {
+ return $false
+ }
+ if ($First.location.file -cne $Second.location.file) {
+ return $false
+ }
+ $firstEnd = if (Test-HasProperty $First.location 'range') { $First.location.range.'end-line' } else { $First.location.line }
+ $secondEnd = if (Test-HasProperty $Second.location 'range') { $Second.location.range.'end-line' } else { $Second.location.line }
+ return $First.location.line -le $secondEnd -and $Second.location.line -le $firstEnd
+ }
+
+ function Test-SameCorrection {
+ param([object] $First, [object] $Second)
+
+ $firstHasCode = Test-HasProperty $First 'suggested-code'
+ $secondHasCode = Test-HasProperty $Second 'suggested-code'
+ if ($firstHasCode -or $secondHasCode) {
+ return $firstHasCode -and $secondHasCode -and
+ [string]::Equals($First.'suggested-code', $Second.'suggested-code', [StringComparison]::Ordinal)
+ }
+ return [string]::Equals($First.message, $Second.message, [StringComparison]::Ordinal)
+ }
+
+ function Test-ReferencesInclude {
+ param([object[]] $RolledReferences, [object[]] $LeafReferences)
+
+ foreach ($leafReference in $LeafReferences) {
+ $matched = @($RolledReferences | Where-Object {
+ if ($_.path -cne $leafReference.path) {
+ return $false
+ }
+ $rolledHasSha = Test-HasProperty $_ 'sha'
+ $leafHasSha = Test-HasProperty $leafReference 'sha'
+ return $rolledHasSha -eq $leafHasSha -and
+ (-not $rolledHasSha -or $_.sha -ceq $leafReference.sha)
+ }).Count
+ if (-not $matched) {
+ return $false
+ }
+ }
+ return $true
+ }
+
+ function Test-RolledFindingRepresents {
+ param(
+ [object] $RolledFinding,
+ [object] $LeafFinding,
+ [string] $LeafProducerId,
+ [switch] $RequirePrimaryOwner
+ )
+
+ $rolledHasLocation = Test-HasProperty $RolledFinding 'location'
+ $leafHasLocation = Test-HasProperty $LeafFinding 'location'
+ $leafReferences = @($LeafFinding.references)
+ $rolledReferences = @($RolledFinding.references)
+ if (-not $rolledHasLocation -and -not $leafHasLocation) {
+ $expectedId = if ($leafReferences.Count) { $LeafFinding.id } else { "${LeafProducerId}:$($LeafFinding.id)" }
+ if ($RolledFinding.'from-sub-skill' -cne $LeafProducerId -or
+ $RolledFinding.id -cne $expectedId -or
+ $RolledFinding.severity -cne $LeafFinding.severity -or
+ $RolledFinding.confidence -cne $LeafFinding.confidence -or
+ -not [string]::Equals($RolledFinding.message, $LeafFinding.message, [StringComparison]::Ordinal) -or
+ $rolledReferences.Count -ne $leafReferences.Count -or
+ -not (Test-ReferencesInclude $rolledReferences $leafReferences)) {
+ return $false
+ }
+ foreach ($name in 'domain', 'suggested-code', 'suggested-code-omission-reason') {
+ $rolledHasProperty = Test-HasProperty $RolledFinding $name
+ $leafHasProperty = Test-HasProperty $LeafFinding $name
+ if ($rolledHasProperty -ne $leafHasProperty -or
+ ($rolledHasProperty -and -not [string]::Equals($RolledFinding.$name, $LeafFinding.$name, [StringComparison]::Ordinal))) {
+ return $false
+ }
+ }
+ return $true
+ }
+
+ if (-not (Test-LocationsOverlap $RolledFinding $LeafFinding) -or
+ (Get-SeverityRank $RolledFinding.severity) -lt (Get-SeverityRank $LeafFinding.severity) -or
+ (Get-ConfidenceRank $RolledFinding.confidence) -lt (Get-ConfidenceRank $LeafFinding.confidence)) {
+ return $false
+ }
+
+ $sameCorrection = Test-SameCorrection $RolledFinding $LeafFinding
+ $correctionsConflict = (Test-HasProperty $RolledFinding 'suggested-code') -and
+ (Test-HasProperty $LeafFinding 'suggested-code') -and
+ -not [string]::Equals($RolledFinding.'suggested-code', $LeafFinding.'suggested-code', [StringComparison]::Ordinal)
+ $explicitCrossRuleMerge = $leafReferences.Count -and
+ $rolledReferences.Count -gt $leafReferences.Count -and
+ -not $correctionsConflict -and
+ (Test-ReferencesInclude $rolledReferences $leafReferences)
+ if (-not $sameCorrection -and -not $explicitCrossRuleMerge) {
+ return $false
+ }
+
+ if (-not $leafReferences.Count) {
+ return $RolledFinding.'from-sub-skill' -ceq $LeafProducerId -and
+ $RolledFinding.id -ceq "${LeafProducerId}:$($LeafFinding.id)" -and
+ -not $rolledReferences.Count
+ }
+ if (-not (Test-ReferencesInclude $rolledReferences $leafReferences)) {
+ return $false
+ }
+ if ($RequirePrimaryOwner) {
+ $rolledHasDomain = Test-HasProperty $RolledFinding 'domain'
+ $leafHasDomain = Test-HasProperty $LeafFinding 'domain'
+ return $RolledFinding.'from-sub-skill' -ceq $LeafProducerId -and
+ $RolledFinding.id -ceq $LeafFinding.id -and
+ @($RolledFinding.references)[0].path -ceq $leafReferences[0].path -and
+ $rolledHasDomain -eq $leafHasDomain -and
+ (-not $rolledHasDomain -or $RolledFinding.domain -ceq $LeafFinding.domain)
+ }
+ return $true
+ }
+
+ function Test-Report {
+ param(
+ [object] $Current,
+ [string] $ReportPathPrefix,
+ [ValidateSet('leaf', 'super')]
+ [string] $CurrentSkillKind
+ )
+
+ $findings = @($Current.findings)
+ foreach ($severity in 'blocker', 'major', 'minor', 'info') {
+ $actual = @($findings | Where-Object severity -CEQ $severity).Count
+ if ($Current.summary.counts.$severity -ne $actual) {
+ Add-Error 'COUNT_MISMATCH' "$ReportPathPrefix.summary.counts.$severity" "Expected $actual."
+ }
+ }
+ $worklistSize = $Current.summary.coverage.'worklist-size'
+ $itemsEvaluated = $Current.summary.coverage.'items-evaluated'
+ if ($itemsEvaluated -gt $worklistSize) {
+ Add-Error 'COVERAGE_INVALID' "$ReportPathPrefix.summary.coverage" 'items-evaluated exceeds worklist-size.'
+ }
+ elseif ($Current.outcome -ceq 'completed' -and $itemsEvaluated -ne $worklistSize) {
+ Add-Error 'COMPLETED_COVERAGE_INCOMPLETE' "$ReportPathPrefix.summary.coverage" 'A completed report must evaluate its full worklist.'
+ }
+ elseif ($CurrentSkillKind -ceq 'leaf' -and $Current.outcome -ceq 'partial' -and
+ ($itemsEvaluated -le 0 -or $itemsEvaluated -ge $worklistSize)) {
+ Add-Error 'PARTIAL_COVERAGE_INVALID' "$ReportPathPrefix.summary.coverage" 'A partial report must evaluate a non-zero proper subset of its worklist.'
+ }
+
+ $hasSubResults = Test-HasProperty $Current 'sub-results'
+ $hasSkippedSubSkills = Test-HasProperty $Current 'skipped-sub-skills'
+ if ($CurrentSkillKind -ceq 'leaf') {
+ if ($hasSubResults -or $hasSkippedSubSkills) {
+ Add-Error 'LEAF_COMPOSITION_INVALID' $ReportPathPrefix 'A leaf report must not contain sub-results or skipped-sub-skills.'
+ }
+ }
+ elseif (-not $hasSubResults) {
+ Add-Error 'SUPER_SUB_RESULTS_REQUIRED' $ReportPathPrefix 'A super-skill report must contain sub-results.'
+ }
+
+ for ($index = 0; $index -lt $findings.Count; $index++) {
+ $finding = $findings[$index]
+ $findingPath = "$ReportPathPrefix.findings[$index]"
+ $references = @($finding.references)
+ $hasProducer = Test-HasProperty $finding 'from-sub-skill'
+ if ($CurrentSkillKind -ceq 'leaf' -and $hasProducer) {
+ Add-Error 'LEAF_PRODUCER_INVALID' "$findingPath.from-sub-skill" 'A leaf finding must not contain from-sub-skill.'
+ }
+ elseif ($CurrentSkillKind -ceq 'super' -and -not $hasProducer) {
+ Add-Error 'SUPER_PRODUCER_REQUIRED' $findingPath 'A super-skill finding must identify its producer in from-sub-skill.'
+ }
+ if (-not $references.Count) {
+ if ($finding.id -cnotmatch '(^|:)agent:[a-z0-9]+(?:-[a-z0-9]+)*$') {
+ Add-Error 'AGENT_ID_INVALID' "$findingPath.id" 'An agent finding id must contain an agent: slug marker.'
+ }
+ if ($finding.confidence -ceq 'high') {
+ Add-Error 'AGENT_CONFIDENCE_INVALID' "$findingPath.confidence" 'Agent confidence cannot be high.'
+ }
+ if ($finding.severity -cin @('blocker', 'major')) {
+ Add-Error 'AGENT_SEVERITY_INVALID' "$findingPath.severity" 'Agent severity cannot exceed minor.'
+ }
+ }
+ else {
+ if ($finding.id -cne $references[0].path) {
+ Add-Error 'PRIMARY_REFERENCE_MISMATCH' "$findingPath.id" 'Finding id must equal the primary reference path.'
+ }
+ foreach ($reference in $references) {
+ if ($reference.path -cnotmatch '^(microsoft|community|custom)/knowledge/.+\.md$') {
+ Add-Error 'REFERENCE_PATH_INVALID' "$findingPath.references" "Invalid knowledge path '$($reference.path)'."
+ continue
+ }
+ if (-not (Test-Path -LiteralPath (Join-Path $BCQualityRoot $reference.path) -PathType Leaf)) {
+ Add-Error 'REFERENCE_MISSING' "$findingPath.references" "Knowledge path '$($reference.path)' does not exist."
+ }
+ if (-not $retrieved.Contains($reference.path)) {
+ Add-Error 'REFERENCE_NOT_RETRIEVED' "$findingPath.references" "Knowledge path '$($reference.path)' was not retrieved in full."
+ }
+ }
+ }
+
+ if (Test-HasProperty $finding 'location') {
+ $location = $finding.location
+ if (-not $sources.Contains($location.file)) {
+ Add-Error 'SOURCE_OUT_OF_SCOPE' "$findingPath.location.file" "Source path '$($location.file)' is outside the supplied scope."
+ }
+ $lineCount = Get-SourceLineCount $location.file
+ if ($lineCount -lt 0) {
+ Add-Error 'SOURCE_MISSING' "$findingPath.location.file" "Source path '$($location.file)' does not exist."
+ }
+ elseif ($location.line -gt $lineCount) {
+ Add-Error 'SOURCE_LINE_INVALID' "$findingPath.location.line" "Line exceeds the file's $lineCount lines."
+ }
+
+ if (Test-HasProperty $location 'range') {
+ $range = $location.range
+ if ($range.'start-line' -ne $location.line) {
+ Add-Error 'RANGE_START_MISMATCH' "$findingPath.location.range.start-line" 'start-line must equal line.'
+ }
+ if ($range.'end-line' -lt $range.'start-line' -or
+ ($lineCount -ge 0 -and $range.'end-line' -gt $lineCount)) {
+ Add-Error 'SOURCE_RANGE_INVALID' "$findingPath.location.range" 'Range is reversed or exceeds the source file.'
+ }
+ }
+ }
+ }
+
+ if ($CurrentSkillKind -ceq 'super' -and $hasSubResults) {
+ $subResults = @($Current.'sub-results')
+ $producerIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ for ($index = 0; $index -lt $subResults.Count; $index++) {
+ if (-not $producerIds.Add([string]$subResults[$index].skill.id)) {
+ Add-Error 'SUPER_DUPLICATE_SUB_RESULT' "$ReportPathPrefix.sub-results[$index].skill.id" `
+ 'A leaf may appear only once in sub-results.'
+ }
+ Test-Report $subResults[$index] "$ReportPathPrefix.sub-results[$index]" 'leaf'
+ }
+
+ $skippedIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ $skips = if ($hasSkippedSubSkills) { @($Current.'skipped-sub-skills') } else { @() }
+ foreach ($skip in $skips) {
+ if (-not $skippedIds.Add([string]$skip.skill.id) -or $producerIds.Contains([string]$skip.skill.id)) {
+ Add-Error 'SUPER_SKIP_CONFLICT' "$ReportPathPrefix.skipped-sub-skills" `
+ "Skill '$($skip.skill.id)' is duplicated or both returned and skipped."
+ }
+ }
+
+ $missingResults = 0
+ if ($null -ne $expectedComposition) {
+ if ($Current.skill.id -cne $expectedComposition.superSkill.id -or
+ $Current.skill.version -ne $expectedComposition.superSkill.version) {
+ Add-Error 'SUPER_IDENTITY_MISMATCH' "$ReportPathPrefix.skill" 'Super-skill identity differs from expected composition.'
+ }
+ $previousSlot = -1
+ $orderedIds = @($expectedComposition.subSkills | ForEach-Object { $_.id })
+ for ($index = 0; $index -lt $subResults.Count; $index++) {
+ $identity = $subResults[$index].skill
+ if (-not $expectedLeaves.ContainsKey([string]$identity.id)) {
+ Add-Error 'SUPER_UNEXPECTED_SUB_RESULT' "$ReportPathPrefix.sub-results[$index].skill" `
+ "Skill '$($identity.id)' was not selected."
+ continue
+ }
+ if ($identity.version -ne $expectedLeaves[$identity.id].version) {
+ Add-Error 'SUPER_LEAF_VERSION_MISMATCH' "$ReportPathPrefix.sub-results[$index].skill.version" `
+ "Unexpected version for '$($identity.id)'."
+ }
+ if (-not $acceptedLeaves.ContainsKey([string]$identity.id)) {
+ Add-Error 'SUPER_LEAF_NOT_ACCEPTED' "$ReportPathPrefix.sub-results[$index]" `
+ "Leaf '$($identity.id)' has no host-captured accepted result."
+ }
+ elseif (-not (Test-JsonContentEqual $subResults[$index] $acceptedLeaves[$identity.id])) {
+ Add-Error 'SUPER_LEAF_CONTENT_MISMATCH' "$ReportPathPrefix.sub-results[$index]" `
+ "Leaf '$($identity.id)' differs from its host-captured accepted result."
+ }
+ $slot = [Array]::IndexOf($orderedIds, $identity.id)
+ if ($slot -le $previousSlot) {
+ Add-Error 'SUPER_SUB_RESULT_ORDER' "$ReportPathPrefix.sub-results[$index].skill" `
+ 'Sub-results must preserve the selected worklist order.'
+ }
+ $previousSlot = $slot
+ }
+ foreach ($leaf in @($expectedComposition.subSkills)) {
+ if (-not $producerIds.Contains([string]$leaf.id)) {
+ $missingResults++
+ if ($acceptedLeaves.ContainsKey([string]$leaf.id)) {
+ Add-Error 'SUPER_ACCEPTED_LEAF_MISSING' "$ReportPathPrefix.sub-results" `
+ "Host-captured accepted leaf '$($leaf.id)' must be included."
+ }
+ $reason = if (Test-HasProperty $Current 'outcome-reason') { $Current.'outcome-reason' } else { '' }
+ $idPattern = '(? |