)` as described in `privacy-notice-consent-for-external-data-transfer.md`.
+Choose a stable ID owned by the extension. Register it through `OnRegisterPrivacyNotices`, or call `PrivacyNotice.CreatePrivacyNotice` during an intentional setup or upgrade path. Use that same ID for consent checks described in `privacy-notice-consent-for-external-data-transfer.md`.
-See sample: `register-integration-in-privacy-notice-registrations.good.al`.
+See sample: [`register-integration-in-privacy-notice-registrations.good.al`](register-integration-in-privacy-notice-registrations.good.al).
## Anti Pattern
-Shipping a new outbound integration without registering it. Even if the code calls `GetPrivacyNoticeApprovalState`, the admin has no surface to express consent โ the integration is effectively unmanaged from a privacy-notice standpoint.
+Reusing the Exchange or another built-in notice ID for a custom integration, subscribing to `Privacy Notice Registrations`, or calling the nonexistent `CreatePrivacyNoticeForIntegration` method. These shapes attach consent to the wrong service or do not compile.
diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al
index e895d7c..6428b08 100644
--- a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al
+++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al
@@ -2,6 +2,6 @@ codeunit 50211 "Privacy Sample LogMessage Bad"
{
procedure LogCompleted()
begin
- Session.LogMessage('0003', 'Operation completed', Verbosity::Normal);
+ Session.LogMessage('PRIV0004', 'Operation completed', Verbosity::Normal);
end;
}
diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al
index d3353ec..3f78158 100644
--- a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al
+++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al
@@ -2,7 +2,7 @@ codeunit 50210 "Privacy Sample LogMessage Good"
{
procedure LogCompleted()
begin
- Session.LogMessage('0003', 'Operation completed', Verbosity::Normal,
+ Session.LogMessage('PRIV0004', 'Operation completed', Verbosity::Normal,
DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher);
end;
}
diff --git a/microsoft/knowledge/privacy/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.good.al b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al
index e1e5808..bb0a8cf 100644
--- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al
+++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al
@@ -4,9 +4,19 @@ table 50202 "System Configuration Log"
fields
{
- field(1; "Entry No."; Integer) { }
- field(2; "Changed By"; Code[50]) { }
- field(3; "Change Description"; Text[250]) { }
+ field(1; "Entry No."; Integer)
+ {
+ }
+ field(2; "Setting Name"; Text[100])
+ {
+ }
+ field(3; "Changed At"; DateTime)
+ {
+ }
+ field(4; "Changed By"; Code[50])
+ {
+ DataClassification = EndUserIdentifiableInformation;
+ }
}
keys
@@ -14,3 +24,17 @@ table 50202 "System Configuration Log"
key(PK; "Entry No.") { Clustered = true; }
}
}
+
+tableextension 50203 "System Config Log Correlation" extends "System Configuration Log"
+{
+ fields
+ {
+ // A table extension cannot set the table-level property and does not inherit
+ // the base table's default, so this field must classify itself even though
+ // SystemMetadata is the value the base table already declares.
+ field(50203; "Correlation Id"; Guid)
+ {
+ DataClassification = SystemMetadata;
+ }
+ }
+}
diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md
index bf457e9..f41dc11 100644
--- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md
+++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md
@@ -1,24 +1,24 @@
---
bc-version: [all]
domain: privacy
-keywords: [data-classification, table-level, inheritance, override, cascading]
+keywords: [data-classification, table-level, field-inheritance, tableextension, appsourcecop, as0016, false-positive]
technologies: [al]
countries: [w1]
application-area: [all]
---
-# Table-level DataClassification cascades to every field unless overridden
+# Table-level DataClassification is inherited by fields
## Description
-`DataClassification` may be set at the table level. When it is, every field in the table inherits that classification and individual fields do not need their own `DataClassification` property. The cascade is the platform's intended way of classifying tables whose fields are homogeneous โ for example, a system configuration log whose every column is `SystemMetadata`. A field only needs its own classification when its content genuinely differs from the table's default and the inherited value would be wrong.
+A valid table-level `DataClassification` is the effective default for the Normal fields declared inside that table object when they do not declare their own value, and AppSourceCop AS0016 accepts those fields rather than reporting them as unclassified. A field-level value overrides that default only for the field on which it is set. The default does not cross object boundaries: a `tableextension` cannot set the table-level property, and the fields it adds do not inherit the base table's value, so every Normal field a table extension adds must classify itself. FlowFields and FlowFilters are handled separately by the platform and are covered by `flowfield-flowfilter-classification-systemmetadata.md`.
## Best Practice
-Set `DataClassification` once at the table level whenever every field in the table shares the same classification. Omit field-level `DataClassification` properties in that case. Override only on the specific fields whose data class differs from the table's โ for example, a `SystemMetadata` audit table that nonetheless captures a `CustomerContent` value somewhere.
+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
-Flagging individual fields for "missing `DataClassification`" when the table declares one โ the inheritance is the correct, intentional pattern. The mirror anti-pattern is repeating the same `DataClassification` on every field of a table that already declares it at the table level; the property is redundant and adds nothing the platform did not already know.
+Reporting every Normal field without an explicit `DataClassification` when its own table already supplies a valid default, or requiring redundant field-level declarations that repeat the table value. The mirror-image mistake is waving through an unclassified Normal field added by a `tableextension` because the base table carries a default โ a table extension inherits nothing. A real issue exists when neither scope supplies a valid classification, when a field's data requires an override of the inherited value, or when a Normal field added by a `tableextension` lacks a valid explicit `DataClassification`.
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.bad.al b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.bad.al
new file mode 100644
index 0000000..7d9411b
--- /dev/null
+++ b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.bad.al
@@ -0,0 +1,28 @@
+query 50426 "Query Reuse Bad"
+{
+ QueryType = Normal;
+
+ elements
+ {
+ dataitem(Customer; Customer)
+ {
+ column(CustomerNo; "No.") { }
+ }
+ }
+}
+
+codeunit 50427 "Query Reuse Bad"
+{
+ procedure ReadAgain(CustomerNoFilter: Code[20])
+ var
+ CustomerQuery: Query "Query Reuse Bad";
+ begin
+ CustomerQuery.SetRange(CustomerNo, CustomerNoFilter);
+ CustomerQuery.Open();
+ if CustomerQuery.Read() then;
+
+ // Reopening resets to the first row and retains CustomerNo.
+ CustomerQuery.Open();
+ if CustomerQuery.Read() then;
+ end;
+}
diff --git a/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.good.al b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.good.al
new file mode 100644
index 0000000..4079455
--- /dev/null
+++ b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.good.al
@@ -0,0 +1,39 @@
+query 50424 "Query Reuse Good"
+{
+ QueryType = Normal;
+
+ elements
+ {
+ dataitem(Customer; Customer)
+ {
+ column(CustomerNo; "No.") { }
+ }
+ }
+}
+
+codeunit 50425 "Query Reuse Good"
+{
+ procedure ReadTwoIndependentSets(FirstNo: Code[20]; SecondNo: Code[20])
+ var
+ CustomerQuery: Query "Query Reuse Good";
+ begin
+ CustomerQuery.SetRange(CustomerNo, FirstNo);
+ ReadAll(CustomerQuery);
+
+ Clear(CustomerQuery);
+ CustomerQuery.SetRange(CustomerNo, SecondNo);
+ ReadAll(CustomerQuery);
+ end;
+
+ local procedure ReadAll(var CustomerQuery: Query "Query Reuse Good")
+ begin
+ CustomerQuery.Open();
+ while CustomerQuery.Read() do
+ ProcessCustomer(CustomerQuery.CustomerNo);
+ CustomerQuery.Close();
+ end;
+
+ local procedure ProcessCustomer(CustomerNo: Code[20])
+ begin
+ end;
+}
diff --git a/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md
new file mode 100644
index 0000000..0d7ca47
--- /dev/null
+++ b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: query
+keywords: [query, open, close, clear, cursor, filters, reuse]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Reopening a Query resets its cursor but keeps its filters
+
+## Description
+
+Calling `Open()` on an already open query first closes the current dataset and opens it again. The next `Read()` starts at the first row; it does not continue from the previous cursor. Reopening also retains filters previously applied to the query variable. Only `Clear(QueryVariable)` resets those filters, so reuse can unexpectedly reread the first row or carry an old filter into a logically separate operation.
+
+## Best Practice
+
+Open once for one read pass. Close after the pass, and call `Clear(QueryVariable)` before reusing the variable for a logically independent query whose filters must start empty. Set the next pass's filters explicitly before reopening.
+
+See sample: [`reopening-query-resets-cursor-but-keeps-filters.good.al`](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`](reopening-query-resets-cursor-but-keeps-filters.bad.al).
diff --git a/microsoft/knowledge/query/set-query-filters-before-open.bad.al b/microsoft/knowledge/query/set-query-filters-before-open.bad.al
new file mode 100644
index 0000000..eed5f9e
--- /dev/null
+++ b/microsoft/knowledge/query/set-query-filters-before-open.bad.al
@@ -0,0 +1,30 @@
+query 50422 "Query Customer Sales Bad"
+{
+ QueryType = Normal;
+
+ elements
+ {
+ dataitem(Customer; Customer)
+ {
+ column(CustomerNo; "No.") { }
+ column(CustomerName; Name) { }
+ }
+ }
+}
+
+codeunit 50423 "Query Filter Order Bad"
+{
+ procedure ReadCustomer(CustomerNoFilter: Code[20])
+ var
+ CustomerSales: Query "Query Customer Sales Bad";
+ begin
+ CustomerSales.Open();
+ CustomerSales.SetRange(CustomerNo, CustomerNoFilter);
+ while CustomerSales.Read() do
+ ProcessCustomer(CustomerSales.CustomerNo);
+ end;
+
+ local procedure ProcessCustomer(CustomerNo: Code[20])
+ begin
+ end;
+}
diff --git a/microsoft/knowledge/query/set-query-filters-before-open.good.al b/microsoft/knowledge/query/set-query-filters-before-open.good.al
new file mode 100644
index 0000000..86bd2d4
--- /dev/null
+++ b/microsoft/knowledge/query/set-query-filters-before-open.good.al
@@ -0,0 +1,31 @@
+query 50420 "Query Customer Sales Good"
+{
+ QueryType = Normal;
+
+ elements
+ {
+ dataitem(Customer; Customer)
+ {
+ column(CustomerNo; "No.") { }
+ column(CustomerName; Name) { }
+ }
+ }
+}
+
+codeunit 50421 "Query Filter Order Good"
+{
+ procedure ReadCustomer(CustomerNoFilter: Code[20])
+ var
+ CustomerSales: Query "Query Customer Sales Good";
+ begin
+ CustomerSales.SetRange(CustomerNo, CustomerNoFilter);
+ CustomerSales.Open();
+ while CustomerSales.Read() do
+ ProcessCustomer(CustomerSales.CustomerNo);
+ CustomerSales.Close();
+ end;
+
+ local procedure ProcessCustomer(CustomerNo: Code[20])
+ begin
+ end;
+}
diff --git a/microsoft/knowledge/query/set-query-filters-before-open.md b/microsoft/knowledge/query/set-query-filters-before-open.md
new file mode 100644
index 0000000..bb82e9d
--- /dev/null
+++ b/microsoft/knowledge/query/set-query-filters-before-open.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: query
+keywords: [query, setfilter, setrange, open, read, dataset, filter-order]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Set Query filters before Open
+
+## Description
+
+`Query.SetFilter` and `Query.SetRange` automatically close an open query dataset. A call placed after `Open()` therefore does not refine the rows already being read; it ends that dataset. The next `Read()` has no open dataset unless the code explicitly calls `Open()` again, so a plausible filter change can turn a working loop into an empty or failing read sequence without a compiler diagnostic.
+
+## Best Practice
+
+Apply every filter before `Open()`, then read the dataset to completion and call `Close()`. When a later branch needs different filters, close or clear the query, set the new filters, and open a new dataset deliberately.
+
+See sample: [`set-query-filters-before-open.good.al`](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`](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/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 (`<`, ``, `
Contributions welcome โ open a PR to refine or extend this article.
-
## Description
The `IncludedPermissionSets` property lets one AL permission set reference another, composing rights out of smaller building blocks. Combined with `Assignable = false` on the building blocks, an extension can ship focused per-module units (a table-data cluster, an API-access cluster) and assemble role-shaped sets that include them. Adding an object updates one building block, and every role-shaped set that includes it inherits the change automatically โ instead of drifting apart across duplicated definitions.
@@ -19,10 +17,10 @@ The `IncludedPermissionSets` property lets one AL permission set reference anoth
Break permission grants into small, focused building blocks, one per cohesive concern. Mark the building blocks `Assignable = false` so administrators do not accidentally assign a fragment. Build role-shaped, `Assignable = true` sets that reference the relevant building blocks through `IncludedPermissionSets`. When the extension grows, the structure absorbs the growth without duplicated edits.
-See sample: `compose-permission-sets-with-included-sets.good.al`.
+See sample: [`compose-permission-sets-with-included-sets.good.al`](compose-permission-sets-with-included-sets.good.al).
## Anti Pattern
Declaring several role-shaped permission sets that each re-enumerate the same object lists. Adding a new table means touching every set by hand; the sets drift apart over time, and subtle authorization bugs appear where one role was updated and a sibling role was not.
-See sample: `compose-permission-sets-with-included-sets.bad.al`.
+See sample: [`compose-permission-sets-with-included-sets.bad.al`](compose-permission-sets-with-included-sets.bad.al).
diff --git a/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md b/microsoft/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md
similarity index 91%
rename from community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md
rename to microsoft/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md
index 5334ab7..bc9a4e8 100644
--- a/community/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md
+++ b/microsoft/knowledge/security/do-not-grant-rights-beyond-a-users-entitlement.md
@@ -9,8 +9,6 @@ application-area: [all]
# Do not grant rights beyond a user's entitlement
-> Contributions welcome โ open a PR to refine or extend this article.
-
## Description
Entitlements are license-level caps on what a user can access, derived automatically from the BC license tier. Permission sets are application-level grants administered on top of the entitlement. A permission set can only grant within the entitlement's boundaries; grants beyond those boundaries are silently clipped at runtime. This means a permission set authored and validated in a developer sandbox (with a broad license) can appear to work correctly there and fail silently in a customer tenant where users hold a narrower entitlement.
@@ -19,8 +17,6 @@ Entitlements are license-level caps on what a user can access, derived automatic
When designing a permission set that ships with an extension, consult the entitlement model for the target user population before finalizing the grants. Every object and tabledata right the set expects to grant should be reachable within the intended entitlement tier; if it is not, the set needs to be scoped to licenses that permit it, or the feature needs a different access path.
-See sample: `do-not-grant-rights-beyond-a-users-entitlement.good.al`.
-
## Anti Pattern
Authoring permission sets in a sandbox with full-license context and shipping them without verifying which entitlement tier customer users actually hold. The sets look complete in test; on a real customer they silently lose rights at runtime and the symptom is "the feature does not work for some users" with no obvious authorization error.
diff --git a/microsoft/knowledge/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/community/knowledge/security/guard-bulk-operations-with-istemporary.bad.al b/microsoft/knowledge/security/guard-bulk-operations-with-istemporary.bad.al
similarity index 100%
rename from community/knowledge/security/guard-bulk-operations-with-istemporary.bad.al
rename to microsoft/knowledge/security/guard-bulk-operations-with-istemporary.bad.al
diff --git a/community/knowledge/security/guard-bulk-operations-with-istemporary.good.al b/microsoft/knowledge/security/guard-bulk-operations-with-istemporary.good.al
similarity index 100%
rename from community/knowledge/security/guard-bulk-operations-with-istemporary.good.al
rename to microsoft/knowledge/security/guard-bulk-operations-with-istemporary.good.al
diff --git a/community/knowledge/security/guard-bulk-operations-with-istemporary.md b/microsoft/knowledge/security/guard-bulk-operations-with-istemporary.md
similarity index 86%
rename from community/knowledge/security/guard-bulk-operations-with-istemporary.md
rename to microsoft/knowledge/security/guard-bulk-operations-with-istemporary.md
index 7cb53f8..3919f6b 100644
--- a/community/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.md b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md
index 206d211..2a39b9c 100644
--- a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md
+++ b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md
@@ -15,8 +15,8 @@ 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 (`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`](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).
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.bad.al b/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.bad.al
new file mode 100644
index 0000000..f662f8c
--- /dev/null
+++ b/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.bad.al
@@ -0,0 +1,15 @@
+codeunit 50471 "Unprotected Setup Action"
+{
+ Access = Internal;
+
+ trigger OnRun()
+ begin
+ // Internal does not prevent another extension from invoking this OnRun
+ // through Codeunit.Run.
+ UpdateSensitiveSetup();
+ end;
+
+ local procedure UpdateSensitiveSetup()
+ begin
+ end;
+}
diff --git a/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.good.al b/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.good.al
new file mode 100644
index 0000000..0691c31
--- /dev/null
+++ b/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.good.al
@@ -0,0 +1,39 @@
+table 50468 "Sensitive Setup"
+{
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "Primary Key"; Code[10]) { }
+ }
+}
+
+codeunit 50469 "Setup Authorization"
+{
+ procedure CanManageSetup(): Boolean
+ var
+ SensitiveSetup: Record "Sensitive Setup";
+ begin
+ exit(SensitiveSetup.WritePermission());
+ end;
+}
+
+codeunit 50470 "Protected Setup Action"
+{
+ Access = Internal;
+
+ trigger OnRun()
+ begin
+ if not SetupAuthorization.CanManageSetup() then
+ Error(NotAuthorizedErr);
+ UpdateSensitiveSetup();
+ end;
+
+ local procedure UpdateSensitiveSetup()
+ begin
+ end;
+
+ var
+ SetupAuthorization: Codeunit "Setup Authorization";
+ NotAuthorizedErr: Label 'You are not authorized to manage this setup.';
+}
diff --git a/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.md b/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.md
new file mode 100644
index 0000000..8f57e22
--- /dev/null
+++ b/microsoft/knowledge/security/internal-access-is-not-a-security-boundary.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: security
+keywords: [access, internal, internalsvisibleto, recordref, codeunit-run, security-boundary, authorization]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Access Internal is API hygiene, not an authorization boundary
+
+## Description
+
+`Access = Internal` controls compile-time symbol visibility. It does not prevent runtime access through mechanisms such as `RecordRef`, `TransferFields`, or `Codeunit.Run`, and `internalsVisibleTo` deliberately grants compile-time access to named companion apps. Microsoft explicitly documents that access modifiers cannot be used as a security boundary.
+
+## Best Practice
+
+Use `internal` to keep implementation details out of the supported API, but enforce sensitive operations with permissions, entitlements, and explicit authorization checks appropriate to the operation. Treat `internalsVisibleTo` as a same-publisher development/testability relationship, not as a trust grant for secrets or elevated data access.
+
+See sample: [`internal-access-is-not-a-security-boundary.good.al`](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`](internal-access-is-not-a-security-boundary.bad.al).
diff --git a/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.good.al b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.good.al
index 22e279b..8dd0069 100644
--- a/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.good.al
+++ b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.good.al
@@ -8,7 +8,7 @@ codeunit 50215 "Sec Sample IsoStorage Good"
exit(true);
end;
- internal procedure SetApiKey(NewKey: Text)
+ internal procedure SetApiKey(NewKey: SecretText)
begin
IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module);
end;
diff --git a/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md
index cbf5d5d..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
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [24..]
domain: security
keywords: [isolatedstorage, local, internal, public, getter, setter, encapsulation]
technologies: [al]
@@ -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.bad.al b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al
index 60efe31..63a8649 100644
--- a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al
+++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al
@@ -1,6 +1,6 @@
codeunit 50220 "Sec Sample DataScope Bad"
{
- internal procedure StoreCompanyWebhook(WebhookUrl: Text)
+ internal procedure StoreCompanyWebhook(WebhookUrl: SecretText)
begin
IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Module);
end;
diff --git a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al
index 397635f..f91bb26 100644
--- a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al
+++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al
@@ -1,11 +1,11 @@
codeunit 50219 "Sec Sample DataScope Good"
{
- internal procedure StoreTenantApiKey(ApiKey: Text)
+ internal procedure StoreTenantApiKey(ApiKey: SecretText)
begin
IsolatedStorage.SetEncrypted('TenantApiKey', ApiKey, DataScope::Module);
end;
- internal procedure StoreCompanyWebhook(WebhookUrl: Text)
+ internal procedure StoreCompanyWebhook(WebhookUrl: SecretText)
begin
IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Company);
end;
diff --git a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md
index 711895d..a91e91f 100644
--- a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md
+++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [24..]
domain: security
keywords: [isolatedstorage, datascope, module, company, user, scope]
technologies: [al]
@@ -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.good.al b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al
index 215055c..ec1adcc 100644
--- a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al
+++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al
@@ -1,10 +1,11 @@
codeunit 50217 "Sec Sample SetEncrypted Good"
{
- internal procedure StoreApiKey(ApiKeyValue: Text)
+ internal procedure StoreApiKey(ApiKeyValue: SecretText)
+ var
+ StoreApiKeyFailedErr: Label 'The API key could not be stored.';
begin
- if StrLen(ApiKeyValue) > 200 then
- Error('API key too long for encrypted storage');
- IsolatedStorage.SetEncrypted('ApiKey', ApiKeyValue, DataScope::Module);
+ if not IsolatedStorage.SetEncrypted('ApiKey', ApiKeyValue, DataScope::Module) then
+ Error(StoreApiKeyFailedErr);
end;
local procedure ReadApiKey(var ApiKey: SecretText): Boolean
diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md
index 4e4f6b9..6f22129 100644
--- a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md
+++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [24..]
domain: security
keywords: [isolatedstorage, setencrypted, encryption, secret, storage]
technologies: [al]
@@ -15,8 +15,8 @@ application-area: [all]
## Best Practice
-Use `IsolatedStorage.SetEncrypted` for every value that meets the definition of a secret. Pair it with the matching retrieval pattern: `IsolatedStorage.Contains` to test for presence and `IsolatedStorage.Get` (preferably with a `SecretText` destination) to read. Constrain the input length before storing โ long values can exceed the encrypted-storage size limit and the write will fail at runtime. See sample: `isolatedstorage-setencrypted-for-sensitive-values.good.al`.
+Use the `SecretText` overloads of `IsolatedStorage.SetEncrypted` and `IsolatedStorage.Get` for values that meet the definition of a secret. Check the optional Boolean result when storage failure needs a controlled error; encrypted values are subject to the documented storage-size limit. See sample: [`isolatedstorage-setencrypted-for-sensitive-values.good.al`](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.bad.al b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al
index a22b60f..ece0fd8 100644
--- a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al
+++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al
@@ -1,19 +1,14 @@
codeunit 50214 "Sec Sample NonDebug Bad"
{
- procedure BuildConnectionString(ApiKey: SecretText): Text
+ procedure CallLegacyOnPremisesConsumer(ApiKey: SecretText)
+ var
+ PlainApiKey: Text;
begin
- exit('Server=db.example.com;Key=' + ApiKey.Unwrap());
+ PlainApiKey := ApiKey.Unwrap();
+ InvokeLegacyConsumer(PlainApiKey);
end;
- procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText)
- var
- ResponseText: Text;
- JsonObject: JsonObject;
- JsonToken: JsonToken;
+ local procedure InvokeLegacyConsumer(ApiKey: Text)
begin
- Response.Content.ReadAs(ResponseText);
- JsonObject.ReadFrom(ResponseText);
- JsonObject.Get('access_token', JsonToken);
- SessionToken := JsonToken.AsValue().AsText();
end;
}
diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al
index 421e9bd..6b4c045 100644
--- a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al
+++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al
@@ -1,21 +1,17 @@
codeunit 50213 "Sec Sample NonDebug Good"
{
[NonDebuggable]
- procedure BuildConnectionString(ApiKey: SecretText): Text
+ procedure CallLegacyOnPremisesConsumer(ApiKey: SecretText)
+ var
+ PlainApiKey: Text;
begin
- exit('Server=db.example.com;Key=' + ApiKey.Unwrap());
+ PlainApiKey := ApiKey.Unwrap();
+ InvokeLegacyConsumer(PlainApiKey);
end;
[NonDebuggable]
- procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText)
- var
- ResponseText: Text;
- JsonObject: JsonObject;
- JsonToken: JsonToken;
+ local procedure InvokeLegacyConsumer(ApiKey: Text)
begin
- Response.Content.ReadAs(ResponseText);
- JsonObject.ReadFrom(ResponseText);
- JsonObject.Get('access_token', JsonToken);
- SessionToken := JsonToken.AsValue().AsText();
+ // The on-premises legacy consumer accepts only Text.
end;
}
diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md
index b214977..ae973a1 100644
--- a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md
+++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [23..]
domain: security
keywords: [nondebuggable, attribute, secrettext, unwrap, debugger]
technologies: [al]
@@ -7,16 +7,16 @@ countries: [w1]
application-area: [all]
---
-# Mark procedures that call SecretText.Unwrap() as [NonDebuggable]
+# On-premises only: protect unavoidable SecretText.Unwrap calls
## Description
-`SecretText` transit โ assignment, parameter passing, and return values โ is auto-protected: the debugger sees a redacted placeholder, not the value. The protection ends the moment code calls `.Unwrap()`, which converts the `SecretText` back to plain `Text`. From that point on, the local variable holding the result is visible in the debugger like any other `Text`. The `[NonDebuggable]` attribute marks a procedure so that none of its locals or parameters are visible to the debugger during execution, which is exactly what is needed for any procedure that performs an `Unwrap()` or that otherwise materializes a secret as `Text` (for example, while parsing a JSON response to extract an access token).
+`SecretText.Unwrap()` is supported only for Business Central on-premises and exists for compatibility. It converts a protected value to plain `Text`, where debugger redaction no longer applies. `[NonDebuggable]` prevents the debugger from inspecting a procedure's parameters and locals, but it does not make the resulting `Text` safe to return, log, or pass through debuggable code.
## Best Practice
-Apply `[NonDebuggable]` to any procedure whose body calls `.Unwrap()` on a `SecretText`, and to any procedure that constructs a `SecretText` from a `Text` source (such as a procedure that reads a JSON response body and converts the resulting `Text` into a `SecretText` for the caller). Keep the unwrap window as small as possible โ ideally a single one-line helper that hands the unwrapped value straight to the consuming API. See sample: `nondebuggable-required-when-unwrapping-secrettext.good.al`.
+In SaaS, keep the value as `SecretText` and use secret-aware APIs instead of unwrapping. For an unavoidable on-premises legacy API that accepts only `Text`, keep the plain-text path as short as possible and mark every procedure in that path `[NonDebuggable]`. Do not return the unwrapped value. See sample: [`nondebuggable-required-when-unwrapping-secrettext.good.al`](nondebuggable-required-when-unwrapping-secrettext.good.al).
## Anti Pattern
-Calling `ApiKey.Unwrap()` inside a procedure that is not marked `[NonDebuggable]`. The unwrapped value is now an ordinary `Text` local and the debugger will display it, defeating the purpose of using `SecretText` in the first place. Reviewers should flag any `Unwrap()` call in a procedure that lacks the attribute, and any procedure that parses a credential out of a response (`access_token`, `id_token`, `client_secret`) without the attribute. See sample: `nondebuggable-required-when-unwrapping-secrettext.bad.al`.
+Calling `Unwrap()` in cloud-targeted code, or calling it in an on-premises procedure that is debuggable or returns the resulting `Text`. Both defeat the protection that `SecretText` provides. See sample: [`nondebuggable-required-when-unwrapping-secrettext.bad.al`](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/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.bad.al b/microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.bad.al
similarity index 100%
rename from community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.bad.al
rename to microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.bad.al
diff --git a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.good.al b/microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.good.al
similarity index 100%
rename from community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.good.al
rename to microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.good.al
diff --git a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md b/microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md
similarity index 86%
rename from community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md
rename to microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md
index f12a4f9..cf0db17 100644
--- a/community/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md
+++ b/microsoft/knowledge/security/prefer-oauth2-over-api-keys-for-external-http-calls.md
@@ -9,8 +9,6 @@ application-area: [all]
# Prefer OAuth2 over API keys for external HTTP calls
-> Contributions welcome โ open a PR to refine or extend this article.
-
## Description
External HTTP integrations from AL can authenticate using OAuth 2.0 (client-credentials for service-to-service, authorization-code for user-delegated), API keys, basic authentication, or credentials in URLs. The mechanisms differ substantially in the blast radius of a leaked secret and in how cleanly tokens can be rotated. OAuth-issued tokens expire on their own schedule and rotate cleanly; API keys and basic-auth passwords typically have to be rotated manually and usually live unencrypted in a configuration table. When the partner supports OAuth, the difference is a material security improvement, not a stylistic preference.
@@ -19,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/community/knowledge/security/protect-sensitive-data-in-temporary-tables.bad.al b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.bad.al
similarity index 100%
rename from community/knowledge/security/protect-sensitive-data-in-temporary-tables.bad.al
rename to microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.bad.al
diff --git a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al
similarity index 90%
rename from community/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al
rename to microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al
index 54bf2bb..a0c9f77 100644
--- a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al
+++ b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.good.al
@@ -19,9 +19,6 @@ codeunit 50100 "Customer Temp Processor"
until Customer.Next() = 0;
ProcessCustomerBuffer(TempCustomer);
-
- // Explicit cleanup on the normal exit path.
- TempCustomer.DeleteAll();
exit(true);
end;
diff --git a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.md
similarity index 65%
rename from community/knowledge/security/protect-sensitive-data-in-temporary-tables.md
rename to microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.md
index 3f4db02..5f50156 100644
--- a/community/knowledge/security/protect-sensitive-data-in-temporary-tables.md
+++ b/microsoft/knowledge/security/protect-sensitive-data-in-temporary-tables.md
@@ -9,20 +9,18 @@ application-area: [all]
# Protect sensitive data in temporary tables
-> Contributions welcome โ open a PR to refine or extend this article.
-
## Description
A temporary record copies data out of the source table into session memory. The platform does not automatically enforce the source table's permission model on the copy, and a value written to a temporary buffer can outlive the procedure that put it there if the buffer is a global or is passed upward. Code that places sensitive rows into a temporary table is therefore responsible for the checks and cleanup the source table would otherwise provide.
## Best Practice
-Validate the caller's read permission on the source table before populating the temporary buffer. Keep the buffer's lifetime as short as the work requires, and delete its contents on every exit path โ including error paths โ so sensitive values do not linger. Prefer local temporary variables over globals for anything carrying sensitive data.
+Validate the caller's read permission on the source table before populating the temporary buffer. Keep the buffer's lifetime as short as the work requires, and prefer local temporary variables over globals for anything carrying sensitive data โ a local buffer's contents are discarded automatically when the procedure returns. When a buffer must be global or is passed back to callers, delete its contents on every exit path โ including error paths โ so sensitive values do not linger.
-See sample: `protect-sensitive-data-in-temporary-tables.good.al`.
+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.bad.al b/microsoft/knowledge/security/secrets-isolated-storage.bad.al
new file mode 100644
index 0000000..7383b8a
--- /dev/null
+++ b/microsoft/knowledge/security/secrets-isolated-storage.bad.al
@@ -0,0 +1,21 @@
+table 50134 "Api Setup Bad Sample"
+{
+ fields
+ {
+ field(1; "Primary Key"; Code[10]) { }
+
+ // A secret in an ordinary Text field is readable by anyone with table
+ // permission, ships in RapidStart packages and Excel exports, and
+ // appears in record snapshots. No DataClassification tag makes it safe;
+ // it belongs in IsolatedStorage instead.
+ field(10; "API Key"; Text[250])
+ {
+ DataClassification = CustomerContent;
+ }
+ }
+
+ keys
+ {
+ key(PK; "Primary Key") { Clustered = true; }
+ }
+}
diff --git a/microsoft/knowledge/security/secrets-isolated-storage.good.al b/microsoft/knowledge/security/secrets-isolated-storage.good.al
new file mode 100644
index 0000000..e6b9f38
--- /dev/null
+++ b/microsoft/knowledge/security/secrets-isolated-storage.good.al
@@ -0,0 +1,15 @@
+codeunit 50134 "Api Credential Good Sample"
+{
+ procedure StoreApiKey(ApiKey: SecretText)
+ begin
+ // Credentials live in IsolatedStorage, invisible to record reads, API
+ // pages, RapidStart packages, and Excel export.
+ IsolatedStorage.SetEncrypted('ExternalApiKey', ApiKey, DataScope::Module);
+ end;
+
+ procedure GetApiKey() ApiKey: SecretText
+ begin
+ if not IsolatedStorage.Get('ExternalApiKey', DataScope::Module, ApiKey) then
+ Error('The external API key has not been configured.');
+ end;
+}
diff --git a/microsoft/knowledge/security/secrets-isolated-storage.md b/microsoft/knowledge/security/secrets-isolated-storage.md
new file mode 100644
index 0000000..feb3c1c
--- /dev/null
+++ b/microsoft/knowledge/security/secrets-isolated-storage.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: security
+keywords: [isolatedstorage, secrets, api-key, oauth-token, connection-string, table-field, credentials]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# A secret belongs in IsolatedStorage, never in a table field
+
+## Description
+
+API keys, OAuth tokens, client secrets, and connection strings must not be stored in an ordinary table `Text` field โ not even on a hidden setup table. A regular field is exposed through record reads, page display, RapidStart and Excel export, report datasets, and surfaces in `DataClassification` review; anyone with table permission can read it. The correct home is `IsolatedStorage`, which is invisible to database queries, API pages, and configuration packages. The storage-*location* decision is the rule here; how to scope and encrypt the value once it is in IsolatedStorage is covered separately.
+
+## Best Practice
+
+Persist every credential 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`](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`](secrets-isolated-storage.bad.al).
diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al
index 84dda45..7ff4232 100644
--- a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al
+++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al
@@ -1,12 +1,17 @@
codeunit 50212 "Sec Sample SecretSubst Bad"
{
- procedure BuildAuthHeader(Token: SecretText): Text
+ procedure BuildAuthHeader(Token: Text): Text
begin
- exit(StrSubstNo('Bearer %1', Token.Unwrap()));
+ exit(StrSubstNo('Token %1', Token));
end;
- procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): Text
+ procedure BuildSecretUri(ApiKey: Text): Text
begin
- exit(BaseUrl + '?key=' + ApiKey.Unwrap());
+ exit(StrSubstNo('https://api.example.com/data?key=%1', ApiKey));
+ end;
+
+ procedure BuildBrokenAuthHeader(Token: SecretText): SecretText
+ begin
+ exit(SecretStrSubstNo('Token', Token));
end;
}
diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al
index f550025..0ef1282 100644
--- a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al
+++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al
@@ -2,11 +2,11 @@ codeunit 50211 "Sec Sample SecretSubst Good"
{
procedure BuildAuthHeader(Token: SecretText): SecretText
begin
- exit(SecretStrSubstNo('Bearer %1', Token));
+ exit(SecretStrSubstNo('Token %1', Token));
end;
- procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): SecretText
+ procedure BuildSecretUri(ApiKey: SecretText): SecretText
begin
- exit(SecretStrSubstNo('%1?key=%2', BaseUrl, ApiKey));
+ exit(SecretStrSubstNo('https://api.example.com/data?key=%1', ApiKey));
end;
}
diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md
index 6e315f7..988d2a6 100644
--- a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md
+++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [23..]
domain: security
keywords: [secretstrsubstno, secrettext, strsubstno, format, compose]
technologies: [al]
@@ -11,12 +11,12 @@ application-area: [all]
## Description
-`SecretStrSubstNo` is the secret-preserving counterpart of `StrSubstNo`. It accepts a format string and arguments (any of which may be `SecretText`) and returns a `SecretText` โ the substitution happens without ever materializing the result as plain `Text`. It is the right tool whenever a secret needs to be embedded in a larger string: an `Authorization: Bearer ` header value, a URI that includes an API key as a query parameter, or any other interpolation that combines a `SecretText` with surrounding context.
+`SecretStrSubstNo` is the secret-preserving counterpart of `StrSubstNo`. It inserts `SecretText` arguments into `%1`, `%2`, and similar placeholders and returns `SecretText` without materializing the result as plain text. It is the right tool for values such as a `Token %1` authorization header or a URI with an API key placeholder.
## Best Practice
-Compose every secret-bearing string through `SecretStrSubstNo` and keep the result as `SecretText` end-to-end. Pass the result to the `SecretText` overload of the consumer โ `HttpClient.SetSecretRequestUri`, `HttpHeaders.Add`, or `HttpContent.WriteFrom`. See sample: `secretstrsubstno-for-composing-secrets.good.al`.
+Compose every secret-bearing string through `SecretStrSubstNo`, ensure the format contains a placeholder for each secret, and keep the result as `SecretText`. Pass it to `HttpRequestMessage.SetSecretRequestUri`, `HttpHeaders.Add`, or `HttpContent.WriteFrom`. See sample: [`secretstrsubstno-for-composing-secrets.good.al`](secretstrsubstno-for-composing-secrets.good.al).
## Anti Pattern
-Calling `StrSubstNo('Bearer %1', Token.Unwrap())` to build the header value, or concatenating `'Bearer ' + Token.Unwrap()`. Both produce a plain `Text` containing the secret, which is then visible in the debugger and in any subsequent log or trace. Reviewers should flag any `Unwrap()` whose result is fed into `StrSubstNo` or used in `+` concatenation โ `SecretStrSubstNo` removes the need for either. See sample: `secretstrsubstno-for-composing-secrets.bad.al`.
+Keeping a credential in `Text` and inserting it with `StrSubstNo`, or calling `SecretStrSubstNo` with a format that has no placeholder for the secret. The first exposes the value as plain text; the second silently omits it. See sample: [`secretstrsubstno-for-composing-secrets.bad.al`](secretstrsubstno-for-composing-secrets.bad.al).
diff --git a/microsoft/knowledge/security/secrettext-for-credentials.good.al b/microsoft/knowledge/security/secrettext-for-credentials.good.al
index d98f127..2eb0355 100644
--- a/microsoft/knowledge/security/secrettext-for-credentials.good.al
+++ b/microsoft/knowledge/security/secrettext-for-credentials.good.al
@@ -1,14 +1,11 @@
codeunit 50207 "Sec Sample SecretText Good"
{
- procedure CallExternalApi()
+ procedure CallExternalApi(ApiKey: SecretText)
var
- ApiKey: SecretText;
HttpClient: HttpClient;
Response: HttpResponseMessage;
Headers: HttpHeaders;
begin
- if IsolatedStorage.Contains('ApiKey', DataScope::Module) then
- IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey);
Headers := HttpClient.DefaultRequestHeaders();
Headers.Add('X-Api-Key', ApiKey);
HttpClient.Get('https://api.example.com/data', Response);
diff --git a/microsoft/knowledge/security/secrettext-for-credentials.md b/microsoft/knowledge/security/secrettext-for-credentials.md
index 17fec22..6f5d368 100644
--- a/microsoft/knowledge/security/secrettext-for-credentials.md
+++ b/microsoft/knowledge/security/secrettext-for-credentials.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [23..]
domain: security
keywords: [secrettext, credentials, api-key, token, debugger, unwrap]
technologies: [al]
@@ -15,8 +15,8 @@ application-area: [all]
## Best Practice
-Declare credential-carrying parameters and variables as `SecretText` from the call site that retrieves the secret all the way to the call site that consumes it (typically an `HttpClient` header or URI). Never round-trip through `Text` โ every conversion is a potential exposure point. Retrieve secrets from `IsolatedStorage` with the `SecretText` overload of `Get` rather than the `Text` overload. See sample: `secrettext-for-credentials.good.al`.
+Declare credential-carrying parameters and variables as `SecretText` from the call site that retrieves the secret all the way to the call site that consumes it (typically an HTTP header or URI). Never round-trip through `Text`. On BC 24 and later, use the `SecretText` overload of `IsolatedStorage.Get` when retrieving stored secrets. See sample: [`secrettext-for-credentials.good.al`](secrettext-for-credentials.good.al).
## Anti Pattern
-Holding a credential in a `Text` variable (`BearerToken: Text`), concatenating it into a header, then passing it to `HttpClient`. The token is visible in the debugger and in any error that prints the variable, and the compiler offers no help because the type was wrong from the start. Reviewers should flag any local or parameter named like a secret (`ApiKey`, `Token`, `Password`, `ClientSecret`) whose type is `Text` or `Code`. See sample: `secrettext-for-credentials.bad.al`.
+Holding a credential in a `Text` variable (`BearerToken: Text`) makes it visible in the debugger and in any error that prints the variable, and the compiler offers no help because the type was wrong from the start. Reviewers should flag any local or parameter named like a secret (`ApiKey`, `Token`, `Password`, `ClientSecret`) whose type is `Text` or `Code`. When the same value is visibly sent through an HTTP URI, header, or body, `secrettext-with-httpclient.md` is the more specific primary rule. See sample: [`secrettext-for-credentials.bad.al`](secrettext-for-credentials.bad.al).
diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.bad.al b/microsoft/knowledge/security/secrettext-with-httpclient.bad.al
index 6ddb883..6b11a4c 100644
--- a/microsoft/knowledge/security/secrettext-with-httpclient.bad.al
+++ b/microsoft/knowledge/security/secrettext-with-httpclient.bad.al
@@ -1,23 +1,23 @@
codeunit 50210 "Sec Sample SecretHttp Bad"
{
- procedure CallApiWithSecretInUri(ApiKey: SecretText)
+ procedure CallApiWithSecretInUri(ApiKey: Text)
var
HttpClient: HttpClient;
Response: HttpResponseMessage;
RequestUri: Text;
begin
- RequestUri := 'https://api.example.com/data?key=' + ApiKey.Unwrap();
+ RequestUri := StrSubstNo('https://api.example.com/data?key=%1', ApiKey);
HttpClient.Get(RequestUri, Response);
end;
- procedure CallApiWithBearer(BearerToken: SecretText)
+ procedure CallApiWithAccessToken(AccessToken: Text)
var
HttpClient: HttpClient;
Response: HttpResponseMessage;
Headers: HttpHeaders;
begin
Headers := HttpClient.DefaultRequestHeaders();
- Headers.Add('Authorization', 'Bearer ' + BearerToken.Unwrap());
+ Headers.Add('Authorization', StrSubstNo('Token %1', AccessToken));
HttpClient.Get('https://api.example.com/data', Response);
end;
}
diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.good.al b/microsoft/knowledge/security/secrettext-with-httpclient.good.al
index 50f0e31..e552512 100644
--- a/microsoft/knowledge/security/secrettext-with-httpclient.good.al
+++ b/microsoft/knowledge/security/secrettext-with-httpclient.good.al
@@ -3,26 +3,32 @@ codeunit 50209 "Sec Sample SecretHttp Good"
procedure CallApiWithSecretUri(ApiKey: SecretText)
var
HttpClient: HttpClient;
+ Request: HttpRequestMessage;
Response: HttpResponseMessage;
SecretUri: SecretText;
begin
SecretUri := SecretStrSubstNo('https://api.example.com/data?key=%1', ApiKey);
- HttpClient.SetSecretRequestUri(SecretUri);
- HttpClient.Get('', Response);
+ Request.Method := 'GET';
+ Request.SetSecretRequestUri(SecretUri);
+ HttpClient.Send(Request, Response);
end;
- procedure CallApiWithBearer(BearerToken: SecretText)
+ procedure CallApiWithAccessToken(AccessToken: SecretText)
var
HttpClient: HttpClient;
+ Request: HttpRequestMessage;
Response: HttpResponseMessage;
Headers: HttpHeaders;
AuthHeader: SecretText;
+ AuthorizationHeaderMissingErr: Label 'Authorization header missing.';
begin
- AuthHeader := SecretStrSubstNo('Bearer %1', BearerToken);
- Headers := HttpClient.DefaultRequestHeaders();
+ Request.Method := 'GET';
+ Request.SetRequestUri('https://api.example.com/data');
+ Request.GetHeaders(Headers);
+ AuthHeader := SecretStrSubstNo('Token %1', AccessToken);
Headers.Add('Authorization', AuthHeader);
if not Headers.ContainsSecret('Authorization') then
- Error('Authorization header missing');
- HttpClient.Get('https://api.example.com/data', Response);
+ Error(AuthorizationHeaderMissingErr);
+ HttpClient.Send(Request, Response);
end;
}
diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.md b/microsoft/knowledge/security/secrettext-with-httpclient.md
index f8be895..1395563 100644
--- a/microsoft/knowledge/security/secrettext-with-httpclient.md
+++ b/microsoft/knowledge/security/secrettext-with-httpclient.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [23..]
domain: security
keywords: [secrettext, httpclient, setsecretrequesturi, containssecret, headers, http]
technologies: [al]
@@ -7,16 +7,16 @@ countries: [w1]
application-area: [all]
---
-# Use the SecretText-aware HttpClient surface for secrets in requests
+# Set secret request URIs on HttpRequestMessage
## Description
-`HttpClient` and its companion types expose a parallel surface that accepts `SecretText` instead of `Text`, so that secret URIs, secret headers, and secret request bodies never round-trip through plain text. The key entry points are: `HttpClient.SetSecretRequestUri()` for URIs that contain secrets (the subsequent `Get`/`Post` is then called with an empty string); `HttpHeaders.Add()` overload that accepts a `SecretText` value for authorization headers; `HttpHeaders.ContainsSecret()` to test whether a secret header is present (the plain `Contains()` returns false for secret headers); `HttpContent.WriteFrom()` and `HttpContent.ReadAs()` overloads that accept and produce `SecretText` for request and response bodies that carry credentials.
+The secret URI API belongs to `HttpRequestMessage`, not `HttpClient`. `HttpRequestMessage.SetSecretRequestUri(SecretText)` keeps a credential-bearing URI protected, and the prepared request is sent with `HttpClient.Send`. Companion APIs also accept `SecretText`, including `HttpHeaders.Add` for authorization headers and `HttpContent.WriteFrom` for secret request bodies.
## Best Practice
-When the URI contains a secret query parameter, compose it as `SecretText` (see `secretstrsubstno-for-composing-secrets.md`), pass it to `SetSecretRequestUri`, and call `Get('', Response)` with an empty string as the URI argument. When the credential is an authorization header, build the header value as `SecretText` and pass it to `Headers.Add`. Use `ContainsSecret` rather than `Contains` to check for the presence of a secret header. See sample: `secrettext-with-httpclient.good.al`.
+Compose a secret URI with `SecretStrSubstNo`, call `Request.SetSecretRequestUri(SecretUri)`, set the request method, and send the request with `HttpClient.Send(Request, Response)`. For authorization, get the request headers, add a `SecretText` value, and use `ContainsSecret` when checking for that header. See sample: [`secrettext-with-httpclient.good.al`](secrettext-with-httpclient.good.al).
## Anti Pattern
-Calling `ApiKey.Unwrap()` to build a URI or header string and passing the resulting `Text` to `HttpClient.Get` or `Headers.Add`. The unwrapped secret is now visible in the debugger, in any HTTP trace that captures the request URI, and in any error that includes the URI. Reviewers should flag any `Unwrap()` call whose result flows into an `HttpClient` argument; the `SecretText` overload exists precisely so the unwrap is not needed. See sample: `secrettext-with-httpclient.bad.al`.
+Holding a credential in `Text`, interpolating it with `StrSubstNo` or concatenation, and passing that plain text to `HttpClient.Get` or `HttpHeaders.Add`. The secret-aware request and header APIs remove the need to materialize the value as `Text`. This HTTP-sink rule supersedes the generic `secrettext-for-credentials.md` rule at the same location. See sample: [`secrettext-with-httpclient.bad.al`](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.good.al b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al
index 9a49bc3..ae414db 100644
--- a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al
+++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al
@@ -2,24 +2,21 @@ tableextension 50223 "Sec Sample VTR Good" extends Customer
{
fields
{
- field(50223; "System Batch ID"; Code[20])
- {
- TableRelation = "Sales Header"."No.";
- ValidateTableRelation = false;
- Editable = false;
- }
- field(50224; "External Customer Ref"; Code[50])
+ field(50223; "External Customer Ref"; Code[50])
{
TableRelation = Customer."No.";
ValidateTableRelation = false;
+ TestTableRelation = false;
+
trigger OnValidate()
var
- Customer: Record Customer;
+ InvalidExternalReferenceErr: Label 'The external customer reference must not contain spaces.';
begin
- if "External Customer Ref" = '' then
- exit;
- if not Customer.Get("External Customer Ref") then
- Error('External customer reference %1 does not exist.', "External Customer Ref");
+ "External Customer Ref" := CopyStr(
+ UpperCase(DelChr("External Customer Ref", '<>', ' ')),
+ 1, MaxStrLen("External Customer Ref"));
+ if StrPos("External Customer Ref", ' ') > 0 then
+ Error(InvalidExternalReferenceErr);
end;
}
}
diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md
index 275587c..6a3a4f4 100644
--- a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md
+++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md
@@ -7,16 +7,16 @@ countries: [w1]
application-area: [all]
---
-# Do not set ValidateTableRelation = false on user-editable fields
+# Handle free-form input when ValidateTableRelation is false
## Description
-`TableRelation` on a field declares that the field's value must exist in another table; the platform validates the value on entry and on `Validate`. Setting `ValidateTableRelation = false` keeps the relation as metadata (used by lookups, by Edit-in-Excel, by APIs) but turns off the runtime check. On a system-controlled, non-editable field that is populated only by the platform or by a posting routine, that is acceptable. On a user-editable field, it is dangerous: users can type any value, and downstream code that assumes the relation holds will read a `Customer` record that does not exist, post to an account that was deleted, or join against missing rows.
+`ValidateTableRelation = false` intentionally lets a user keep free-form input even when it does not match `TableRelation`. This is supported for scenarios such as accepting a new vendor name and handling it in `OnValidate`. The risk is not the property itself; it is leaving downstream code to assume that every value identifies an existing related record.
## Best Practice
-Leave `ValidateTableRelation` at its default (true) on any field a user can edit. If there is a legitimate reason to turn it off โ typically because the relation is not on the primary key, or because the relation is computed โ replace it with an `OnValidate` trigger that performs the equivalent check (`if FieldValue <> '' then VerifyExternalReferenceExists(FieldValue)`). Combine `ValidateTableRelation = false` with `Editable = false` for system-controlled fields, so the metadata is correct and the field is unreachable from the UI. See sample: `validatetablerelation-false-on-user-input.good.al`.
+Keep the default validation when values must exist in the related table. When free-form values are intentional, set both `ValidateTableRelation = false` and `TestTableRelation = false`, then add compensating `OnValidate` logic that normalizes, validates, creates, or otherwise handles unmatched input. Document that downstream code must not assume the relation exists. See sample: [`validatetablerelation-false-on-user-input.good.al`](validatetablerelation-false-on-user-input.good.al).
## Anti Pattern
-`ValidateTableRelation = false` on a user-facing input field (a `Customer No.` typed by a sales user) with no alternative validation. Reviewers should flag the combination of `ValidateTableRelation = false` and any of: `Editable = true` (the default), an `OnValidate` trigger that does not perform the relation check, or a page that surfaces the field as input. See sample: `validatetablerelation-false-on-user-input.bad.al`.
+`ValidateTableRelation = false` on a user-facing field with no intentional handling for unmatched values, or leaving `TestTableRelation = true` so database relation tests reject values the UI deliberately accepts. See sample: [`validatetablerelation-false-on-user-input.bad.al`](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 f72b959..2ab9383 100644
--- a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md
+++ b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [21..]
domain: style
keywords: [abouttitle, abouttext, teaching-tip, onboarding, page]
technologies: [al]
@@ -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/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/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/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 bd12f36..21dccb3 100644
--- a/microsoft/knowledge/style/caption-required-on-page-fields.bad.al
+++ b/microsoft/knowledge/style/caption-required-on-page-fields.bad.al
@@ -1,13 +1,30 @@
-table 50253 "Sample Caption Bad"
+page 50253 "Sample Caption Bad"
{
- fields
+ PageType = Card;
+ SourceTable = Customer;
+
+ layout
{
- field(1; "Customer No."; Code[20])
+ area(Content)
{
- }
- field(2; "Is Active"; Boolean)
- {
- Caption = '';
+ group(General)
+ {
+ 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 7de715b..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,17 +1,63 @@
-table 50252 "Sample Caption Good"
+// BC24 / runtime 13.0 or later for table-field tooltips.
+table 50252 "Sample Caption Source"
{
+ Caption = 'Caption Source';
+ DataClassification = CustomerContent;
+
fields
{
- field(1; "Customer No."; Code[20])
+ field(1; "No."; Code[20])
{
- Caption = 'Customer No.';
+ Caption = 'No.';
+ ToolTip = 'Specifies the unique number used to distinguish this customer record from other records.';
}
- field(2; "Enabled"; Boolean)
+ field(2; Name; Text[100])
{
+ Caption = 'Name';
+ ToolTip = 'Specifies the name used to identify the customer alongside the unique customer number.';
}
- field(3; Amount; Decimal)
+ }
+
+ keys
+ {
+ key(PK; "No.")
{
- CaptionClass = '3,5,' + 'USD';
+ Clustered = true;
}
}
}
+
+page 50252 "Sample Caption Good"
+{
+ PageType = Card;
+ SourceTable = "Sample Caption Source";
+
+ layout
+ {
+ area(Content)
+ {
+ group(General)
+ {
+ Caption = 'General';
+ field("No."; Rec."No.")
+ {
+ ApplicationArea = All;
+ }
+ field("Customer Name"; Rec.Name)
+ {
+ ApplicationArea = All;
+ Caption = '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/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 aaf3729..0000000
--- a/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [event-subscriber, parameter-name, publisher, signature, eventsubscriber]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Event subscriber parameter names must match the publisher signature
-
-## Description
-
-In AL, an `[EventSubscriber]` procedure is bound to its publisher by event name and parameter list. The parameter names on the subscriber are not a style choice โ they must match the names the publisher declared. The compiler validates the match at build time and emits an error if the subscriber renames a parameter. This means a reviewer cannot apply a generic "use better names" pass to subscriber parameters: `Sender`, `Rec`, `xRec`, `RunTrigger`, the table-and-field-specific parameter names a publisher emits โ all are dictated by the publisher and must be reproduced verbatim.
-
-## Best Practice
-
-Copy the publisher signature exactly when declaring the subscriber. When in doubt, navigate to the publisher (`OnAfterValidateEvent`, `OnBeforePostSalesDoc`, etc.) and copy its parameter list. Style rules that apply to other locals โ descriptive names, no spaces โ do not apply to subscriber parameters.
-
-## Anti Pattern
-
-Renaming a publisher parameter to look prettier in the subscriber. The build breaks immediately. More insidiously, a parameter name that happens to match by coincidence in one event publisher but not in a similar one will compile in some versions of BC and fail in others when the publisher signature evolves.
diff --git a/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.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/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/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/labels-declared-at-object-scope.bad.al b/microsoft/knowledge/style/labels-declared-at-object-scope.bad.al
deleted file mode 100644
index 33c63fb..0000000
--- a/microsoft/knowledge/style/labels-declared-at-object-scope.bad.al
+++ /dev/null
@@ -1,11 +0,0 @@
-codeunit 50262 "Sample Label Scope Bad"
-{
- procedure LookupCustomer(CustomerNo: Code[20])
- var
- Customer: Record Customer;
- GreetingMsg: Label 'Hello %1', Comment = '%1 = Customer Name';
- begin
- if Customer.Get(CustomerNo) then
- Message(GreetingMsg, Customer.Name);
- end;
-}
diff --git a/microsoft/knowledge/style/labels-declared-at-object-scope.good.al b/microsoft/knowledge/style/labels-declared-at-object-scope.good.al
deleted file mode 100644
index 415bda7..0000000
--- a/microsoft/knowledge/style/labels-declared-at-object-scope.good.al
+++ /dev/null
@@ -1,13 +0,0 @@
-codeunit 50263 "Sample Label Scope Good"
-{
- var
- GreetingMsg: Label 'Hello %1', Comment = '%1 = Customer Name';
-
- procedure LookupCustomer(CustomerNo: Code[20])
- var
- Customer: Record Customer;
- begin
- if Customer.Get(CustomerNo) then
- Message(GreetingMsg, Customer.Name);
- end;
-}
diff --git a/microsoft/knowledge/style/labels-declared-at-object-scope.md b/microsoft/knowledge/style/labels-declared-at-object-scope.md
index 24a868e..91d75d7 100644
--- a/microsoft/knowledge/style/labels-declared-at-object-scope.md
+++ b/microsoft/knowledge/style/labels-declared-at-object-scope.md
@@ -1,30 +1,18 @@
---
bc-version: [all]
domain: style
-keywords: [label, scope, procedure, translation, localization, xliff]
+keywords: [label, scope, procedure, translation, localization, xliff, false-positive]
technologies: [al]
countries: [w1]
application-area: [all]
---
-# Declare Labels at object scope, not inside procedure `var` blocks
+# Procedure-local Labels are valid
## Description
-`Label` is the AL declaration that participates in the translation pipeline: the build extracts every Label declared in an object into the `.xlf` file shipped to translators, and the runtime substitutes the localized value when the object is loaded. Translation tooling discovers Labels by walking the object's top-level declarations.
-
-Labels declared inside a procedure-local `var` block are still **compiled** as Label values, but their participation in localization is fragile: depending on the BC version, the build pipeline, and the translation toolchain in use, procedure-local Labels may be missed during XLIFF extraction, may be re-emitted with auto-generated keys that change between builds, or may not be addressable by reviewers triaging translations. The reliable, supported pattern is to declare every Label in the object's top-level `var` block.
-
-The same rule applies to all object types that own behavior: codeunits, pages, tables, reports, queries, and their extensions. For shared messages used by multiple objects, declare the Label in the most appropriate owning object and reference it โ do not duplicate the literal across procedure-scoped declarations in several places.
+The AL language supports `Label` variables at both object and procedure scope. Microsoft documents the [Label data type](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-using-labels#label-data-type) without imposing an object-scope requirement, and the translation pipeline generates an XLF file containing [all labels used by the extension](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-work-with-translation-files#generating-the-xliff-file). There is no documented correctness or localization defect caused solely by declaring a Label in a procedure-local `var` block.
## Best Practice
-Move every `Label` to the object's top-level `var` block. Use the appropriate suffix (`Msg`, `Err`, `Qst`, `Lbl`, `Tok`, `Txt`) on the variable name so reviewers and the translation team can see at a glance what role the string plays. Pair non-translatable strings (URLs, JSON/XML fragments, integration tokens) with `Locked = true`, as covered by `label-locked-for-non-translatable.md`.
-
-See sample: `labels-declared-at-object-scope.good.al`.
-
-## Anti Pattern
-
-Declaring `Label` inside a procedure-local `var` block โ `procedure Lookup() var GreetingMsg: Label 'Hello %1';` โ couples the translatable string to one procedure, hides it from object-level review, and depends on a translation pipeline behavior that is not part of the AL language contract.
-
-See sample: `labels-declared-at-object-scope.bad.al`.
+Choose object scope when a Label is reused or when an established repository convention prefers central declarations; choose procedure scope when the Label belongs to one procedure. Do not report a correctness or localization finding solely because a Label is local. An explicit object-scope convention is at most a low-severity maintainability preference. This guidance applies equally to production and test apps: test code still needs localization where its strings are user-facing or translator-facing.
diff --git a/microsoft/knowledge/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/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/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/temporary-variable-temp-prefix.md b/microsoft/knowledge/style/temporary-variable-temp-prefix.md
index 2211b4c..2df9636 100644
--- a/microsoft/knowledge/style/temporary-variable-temp-prefix.md
+++ b/microsoft/knowledge/style/temporary-variable-temp-prefix.md
@@ -15,12 +15,12 @@ A `Record` variable declared with the `temporary` modifier behaves nothing like
## Best Practice
-Every variable of type `Record X temporary` must start with `Temp`. The same applies to parameters: a procedure that receives a temporary record as a buffer names the parameter `TempBuffer`, `TempSalesLine`, and so on. The convention extends naturally to derived names โ `TempJobWIPBufferCopy`, `TempSourceSalesLine` โ anything that starts with `Temp` is in-memory.
+Every local or global variable of type `Record X temporary` must start with `Temp`. Ordinary procedure parameters follow the same convention. Event publisher parameters are owned by the events-domain rule `prefix-temporary-record-event-parameters-with-temp.md`; the style leaf must not emit a second finding for the same event parameter.
-See sample: `temporary-variable-temp-prefix.good.al`.
+See sample: [`temporary-variable-temp-prefix.good.al`](temporary-variable-temp-prefix.good.al).
## Anti Pattern
-`WIPBuffer: Record "Job WIP Buffer" temporary;` reads at the call site as if it were a database operation: `WIPBuffer.Insert()` looks identical to a write to the underlying table. The reader has to scroll back to the declaration to discover that this is in-memory, every time.
+`WIPBuffer: Record "Job WIP Buffer" temporary;` as a local, global, or ordinary procedure parameter reads at the call site as if it were a database operation. Exclude event publisher parameters here so the events leaf remains their single owner.
-See sample: `temporary-variable-temp-prefix.bad.al`.
+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 ffcf5a1..0000000
--- a/microsoft/knowledge/style/this-keyword-in-codeunits.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [this, codeunit, self-reference, aa0248, scope]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Use the `this` keyword for self-reference inside codeunits (CodeCop AA0248)
-
-## Description
-
-CodeCop AA0248 recommends prefixing self-references inside a codeunit with `this`. `this.ValidateCustomer(Customer)` is unambiguous: the call resolves to a procedure on the current codeunit, not to a local variable or a procedure on a passed-in object. Without the prefix, a reader of a 200-line procedure has to scan the whole codeunit to confirm whether `ValidateCustomer` is local. `this` also makes it possible to pass the current codeunit as an argument โ `SomeOtherCodeunit.DoWork(this)` โ which is the only way to expose the running codeunit instance to a collaborator. The rule applies only to codeunits, not to pages, reports, queries, or tables โ those object types do not have a `this` reference in AL.
-
-## Best Practice
-
-Inside a codeunit, prefix calls to procedures and accesses to global variables on the same codeunit with `this.`, and pass `this` when an external codeunit needs a reference to the running instance.
-
-See sample: `this-keyword-in-codeunits.good.al`.
-
-## Anti Pattern
-
-Calling a codeunit-local procedure as a bare identifier (`ValidateCustomer(Customer)`) when other readings are possible. The ambiguity costs reading time on every encounter and grows with codeunit size.
-
-See sample: `this-keyword-in-codeunits.bad.al`.
diff --git a/microsoft/knowledge/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 fc5a3cb..34f74c5 100644
--- a/microsoft/knowledge/style/tooltip-required-on-page-fields.md
+++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.md
@@ -1,28 +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'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".
+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/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.bad.al b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.bad.al
new file mode 100644
index 0000000..bd87b07
--- /dev/null
+++ b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.bad.al
@@ -0,0 +1,26 @@
+codeunit 50401 "Telemetry Scope Bad"
+{
+ procedure LogIntegrationFailure()
+ begin
+ // Tenant operators cannot see an actionable integration failure.
+ Session.LogMessage(
+ 'TLM0004',
+ 'Document exchange failed',
+ Verbosity::Error,
+ DataClassification::SystemMetadata,
+ TelemetryScope::ExtensionPublisher,
+ 'Operation', 'DocumentExchange');
+ end;
+
+ procedure LogCacheMiss()
+ begin
+ // Environment telemetry receives publisher-only implementation noise.
+ Session.LogMessage(
+ 'TLM0005',
+ 'Internal cache entry missed',
+ Verbosity::Verbose,
+ DataClassification::SystemMetadata,
+ TelemetryScope::All,
+ 'Cache', 'ExchangeMetadata');
+ end;
+}
diff --git a/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.good.al b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.good.al
new file mode 100644
index 0000000..3233042
--- /dev/null
+++ b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.good.al
@@ -0,0 +1,24 @@
+codeunit 50400 "Telemetry Scope Good"
+{
+ procedure LogIntegrationFailure()
+ begin
+ Session.LogMessage(
+ 'TLM0002',
+ 'Document exchange failed',
+ Verbosity::Error,
+ DataClassification::SystemMetadata,
+ TelemetryScope::All,
+ 'Operation', 'DocumentExchange');
+ end;
+
+ procedure LogCacheMiss()
+ begin
+ Session.LogMessage(
+ 'TLM0003',
+ 'Internal cache entry missed',
+ Verbosity::Verbose,
+ DataClassification::SystemMetadata,
+ TelemetryScope::ExtensionPublisher,
+ 'Cache', 'ExchangeMetadata');
+ end;
+}
diff --git a/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md
new file mode 100644
index 0000000..c60887c
--- /dev/null
+++ b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md
@@ -0,0 +1,26 @@
+---
+bc-version: [17..]
+domain: telemetry
+keywords: [telemetryscope, extensionpublisher, all, audience, logmessage, application-insights]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Choose TelemetryScope by who must receive the signal
+
+## Description
+
+`TelemetryScope::ExtensionPublisher` sends a custom trace only to the Application Insights resource configured by the extension publisher. `TelemetryScope::All` also sends it to the environment's telemetry, where the customer or partner operating the tenant can query it. The compiler accepts either value, so a plausible-looking scope can silently hide an actionable signal from tenant operators or expose publisher-only implementation noise to them.
+
+## Best Practice
+
+Use `ExtensionPublisher` for internal diagnostics that only the app publisher can interpret, such as cache behavior or private algorithm state. Use `All` for signals the tenant operator can act on, such as an integration failure, quota warning, or setup problem. Decide the audience independently from `DataClassification`; privacy guidance still governs whether the payload may be emitted at all.
+
+See sample: [`choose-telemetry-scope-by-audience.good.al`](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`](choose-telemetry-scope-by-audience.bad.al).
diff --git a/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.bad.al b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.bad.al
new file mode 100644
index 0000000..39f2940
--- /dev/null
+++ b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.bad.al
@@ -0,0 +1,11 @@
+codeunit 50405 "Feature Uptake Bad"
+{
+ procedure FeatureOpened()
+ var
+ FeatureTelemetry: Codeunit "Feature Telemetry";
+ begin
+ // The first uptake state skips Discovered and is not emitted.
+ FeatureTelemetry.LogUptake(
+ 'TLM0011', 'Document exchange', Enum::"Feature Uptake Status"::Used);
+ end;
+}
diff --git a/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.good.al b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.good.al
new file mode 100644
index 0000000..323bdd6
--- /dev/null
+++ b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.good.al
@@ -0,0 +1,26 @@
+codeunit 50404 "Feature Uptake Good"
+{
+ procedure FeatureDiscovered()
+ var
+ FeatureTelemetry: Codeunit "Feature Telemetry";
+ begin
+ FeatureTelemetry.LogUptake(
+ 'TLM0008', 'Document exchange', Enum::"Feature Uptake Status"::Discovered);
+ end;
+
+ procedure FeatureSetUp()
+ var
+ FeatureTelemetry: Codeunit "Feature Telemetry";
+ begin
+ FeatureTelemetry.LogUptake(
+ 'TLM0009', 'Document exchange', Enum::"Feature Uptake Status"::"Set up");
+ end;
+
+ procedure FeatureUsed()
+ var
+ FeatureTelemetry: Codeunit "Feature Telemetry";
+ begin
+ FeatureTelemetry.LogUptake(
+ 'TLM0010', 'Document exchange', Enum::"Feature Uptake Status"::Used);
+ end;
+}
diff --git a/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md
new file mode 100644
index 0000000..ae632f9
--- /dev/null
+++ b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md
@@ -0,0 +1,26 @@
+---
+bc-version: [18..]
+domain: telemetry
+keywords: [featuretelemetry, loguptake, discovered, set-up, used, uptake-status, lifecycle]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Emit FeatureTelemetry uptake states in lifecycle order
+
+## Description
+
+`FeatureTelemetry.LogUptake` accepts `Discovered`, `Set up`, `Used`, and `Undiscovered`, but the platform records the forward transition only as `Discovered` to `Set up` to `Used`. If the first call for a feature is `Set up` or `Used`, no uptake telemetry is emitted. `Undiscovered` is the explicit reset from any state.
+
+## Best Practice
+
+Log `Discovered` when the user encounters the feature, `Set up` after its setup is completed, and `Used` when the user attempts it. Keep the same feature name throughout the funnel. Review ordering only when the changed repository context shows the feature's lifecycle; a single isolated `Used` call cannot prove that earlier states are absent elsewhere.
+
+See sample: [`feature-uptake-transitions-in-order.good.al`](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`](feature-uptake-transitions-in-order.bad.al).
diff --git a/microsoft/knowledge/telemetry/feature-usage-only-after-success.bad.al b/microsoft/knowledge/telemetry/feature-usage-only-after-success.bad.al
new file mode 100644
index 0000000..9c3e2a2
--- /dev/null
+++ b/microsoft/knowledge/telemetry/feature-usage-only-after-success.bad.al
@@ -0,0 +1,23 @@
+codeunit 50407 "Feature Usage Bad"
+{
+ procedure ExchangeDocument(ShouldFail: Boolean)
+ var
+ FeatureTelemetry: Codeunit "Feature Telemetry";
+ begin
+ FeatureTelemetry.LogUsage(
+ 'TLM0014', 'Document exchange', 'Document exchanged');
+
+ if not TryExchangeDocument(ShouldFail) then
+ exit;
+ end;
+
+ [TryFunction]
+ local procedure TryExchangeDocument(ShouldFail: Boolean)
+ begin
+ if ShouldFail then
+ Error(ExchangeFailedErr);
+ end;
+
+ var
+ ExchangeFailedErr: Label 'Exchange failed.';
+}
diff --git a/microsoft/knowledge/telemetry/feature-usage-only-after-success.good.al b/microsoft/knowledge/telemetry/feature-usage-only-after-success.good.al
new file mode 100644
index 0000000..8ce7616
--- /dev/null
+++ b/microsoft/knowledge/telemetry/feature-usage-only-after-success.good.al
@@ -0,0 +1,27 @@
+codeunit 50406 "Feature Usage Good"
+{
+ procedure ExchangeDocument(ShouldFail: Boolean)
+ var
+ FeatureTelemetry: Codeunit "Feature Telemetry";
+ begin
+ if not TryExchangeDocument(ShouldFail) then begin
+ FeatureTelemetry.LogError(
+ 'TLM0012', 'Document exchange', 'Exchanging document',
+ GetLastErrorText(true), GetLastErrorCallStack());
+ exit;
+ end;
+
+ FeatureTelemetry.LogUsage(
+ 'TLM0013', 'Document exchange', 'Document exchanged');
+ end;
+
+ [TryFunction]
+ local procedure TryExchangeDocument(ShouldFail: Boolean)
+ begin
+ if ShouldFail then
+ Error(ExchangeFailedErr);
+ end;
+
+ var
+ ExchangeFailedErr: Label 'Exchange failed.';
+}
diff --git a/microsoft/knowledge/telemetry/feature-usage-only-after-success.md b/microsoft/knowledge/telemetry/feature-usage-only-after-success.md
new file mode 100644
index 0000000..cc642d9
--- /dev/null
+++ b/microsoft/knowledge/telemetry/feature-usage-only-after-success.md
@@ -0,0 +1,26 @@
+---
+bc-version: [18..]
+domain: telemetry
+keywords: [featuretelemetry, logusage, logerror, success, tryfunction, feature-usage]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Call FeatureTelemetry.LogUsage only after successful use
+
+## Description
+
+`FeatureTelemetry.LogUsage` means that a user successfully used the feature. An attempt belongs in the uptake funnel, while a failed operation belongs in `LogError`. Logging usage before checking the result inflates adoption metrics with failed attempts and makes usage telemetry disagree with the actual business outcome.
+
+## Best Practice
+
+Call `LogUsage` only after the operation has completed successfully. On a failure path, call `LogError` with the captured error text and call stack when the failure must be emitted explicitly. Use a past-tense event name for usage and a present-tense scenario name for errors.
+
+See sample: [`feature-usage-only-after-success.good.al`](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`](feature-usage-only-after-success.bad.al).
diff --git a/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.bad.al b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.bad.al
new file mode 100644
index 0000000..4abc1c9
--- /dev/null
+++ b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.bad.al
@@ -0,0 +1,14 @@
+codeunit 50412 "Telemetry Dimension Bad"
+{
+ procedure LogBatchResult(RecordCount: Integer)
+ var
+ CustomDimensions: Dictionary of [Text, Text];
+ begin
+ CustomDimensions.Add('record count', Format(RecordCount));
+ CustomDimensions.Add('result_code', 'Success');
+ Session.LogMessage(
+ 'TLM0015', 'Order processing completed', Verbosity::Normal,
+ DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher,
+ CustomDimensions);
+ end;
+}
diff --git a/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.good.al b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.good.al
new file mode 100644
index 0000000..e8f99af
--- /dev/null
+++ b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.good.al
@@ -0,0 +1,14 @@
+codeunit 50411 "Telemetry Dimension Good"
+{
+ procedure LogBatchResult(RecordCount: Integer)
+ var
+ CustomDimensions: Dictionary of [Text, Text];
+ begin
+ CustomDimensions.Add('RecordCount', Format(RecordCount));
+ CustomDimensions.Add('Result', 'Success');
+ Session.LogMessage(
+ 'TLM0015', 'Order processing completed', Verbosity::Normal,
+ DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher,
+ CustomDimensions);
+ end;
+}
diff --git a/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md
new file mode 100644
index 0000000..9291219
--- /dev/null
+++ b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md
@@ -0,0 +1,26 @@
+---
+bc-version: [17..]
+domain: telemetry
+keywords: [customdimensions, dimension-key, schema, pascalcase, kql, breaking-change]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Treat custom dimension keys as a stable telemetry schema
+
+## Description
+
+Business Central prefixes AL custom-dimension keys with `al` in Application Insights, so an AL key named `Result` becomes `alResult`. Microsoft guidance treats telemetry definitions as an API: changing or removing a custom dimension can break dashboards and alerts. PascalCase keys without spaces also compose cleanly in KQL; spaces force awkward bracket access and make queries harder to maintain.
+
+## Best Practice
+
+Choose stable PascalCase keys such as `Operation`, `Result`, and `RecordCount`. Keep the key set and meaning stable for a shipped event ID; add a new event ID or coordinate a schema migration when the meaning must change. Privacy guidance separately governs whether a dimension value may contain customer data.
+
+See sample: [`keep-custom-dimension-schema-stable.good.al`](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`](keep-custom-dimension-schema-stable.bad.al).
diff --git a/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.bad.al b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.bad.al
new file mode 100644
index 0000000..d31fa23
--- /dev/null
+++ b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.bad.al
@@ -0,0 +1,24 @@
+codeunit 50403 "Telemetry Verbosity Bad"
+{
+ procedure RunExchange()
+ begin
+ if TryExchange() then
+ exit;
+
+ Session.LogMessage(
+ 'TLM0007',
+ 'Document exchange failed',
+ Verbosity::Normal,
+ DataClassification::SystemMetadata,
+ TelemetryScope::All);
+ end;
+
+ [TryFunction]
+ local procedure TryExchange()
+ begin
+ Error(ExchangeFailedErr);
+ end;
+
+ var
+ ExchangeFailedErr: Label 'Exchange failed.';
+}
diff --git a/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.good.al b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.good.al
new file mode 100644
index 0000000..c730b1b
--- /dev/null
+++ b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.good.al
@@ -0,0 +1,24 @@
+codeunit 50402 "Telemetry Verbosity Good"
+{
+ procedure RunExchange()
+ begin
+ if TryExchange() then
+ exit;
+
+ Session.LogMessage(
+ 'TLM0006',
+ 'Document exchange failed',
+ Verbosity::Error,
+ DataClassification::SystemMetadata,
+ TelemetryScope::All);
+ end;
+
+ [TryFunction]
+ local procedure TryExchange()
+ begin
+ Error(ExchangeFailedErr);
+ end;
+
+ var
+ ExchangeFailedErr: Label 'Exchange failed.';
+}
diff --git a/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md
new file mode 100644
index 0000000..cec444a
--- /dev/null
+++ b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md
@@ -0,0 +1,26 @@
+---
+bc-version: [17..]
+domain: telemetry
+keywords: [verbosity, severitylevel, critical, error, warning, normal, verbose, logmessage]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Match telemetry Verbosity to the signal's actual severity
+
+## Description
+
+`Verbosity` becomes the Application Insights `severityLevel` and participates in on-premises diagnostic trace filtering. `Critical` represents abnormal termination, `Error` a severe error, `Warning` a warning, `Normal` a non-error event, and `Verbose` detailed tracing. Logging a caught failure as `Normal` is not cosmetic: severity-based alerts miss it, and an on-premises service configured to emit only warnings and above can drop it completely.
+
+## Best Practice
+
+Use `Error` for failed operations that need investigation and `Critical` only for abnormal termination or equivalent loss of service. Use `Warning` for degraded but completed behavior, `Normal` for successful business events, and `Verbose` for detailed diagnostics. Judge the outcome, not the procedure name: an expected optional lookup miss can legitimately remain `Normal` or `Verbose`.
+
+See sample: [`match-verbosity-to-signal-severity.good.al`](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`](match-verbosity-to-signal-severity.bad.al).
diff --git a/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.bad.al b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.bad.al
new file mode 100644
index 0000000..e186247
--- /dev/null
+++ b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.bad.al
@@ -0,0 +1,37 @@
+codeunit 50409 "First Telemetry Logger" implements "Telemetry Logger"
+{
+ Access = Internal;
+
+ procedure LogMessage(EventId: Text; Message: Text; Verbosity: Verbosity; DataClassification: DataClassification; TelemetryScope: TelemetryScope; CustomDimensions: Dictionary of [Text, Text])
+ begin
+ Session.LogMessage(
+ EventId, Message, Verbosity, DataClassification, TelemetryScope, CustomDimensions);
+ end;
+
+ [EventSubscriber(ObjectType::Codeunit, Codeunit::"Telemetry Loggers", 'OnRegisterTelemetryLogger', '', true, true)]
+ local procedure RegisterFirst(var Sender: Codeunit "Telemetry Loggers")
+ var
+ Logger: Codeunit "First Telemetry Logger";
+ begin
+ Sender.Register(Logger);
+ end;
+}
+
+codeunit 50410 "Second Telemetry Logger" implements "Telemetry Logger"
+{
+ Access = Internal;
+
+ procedure LogMessage(EventId: Text; Message: Text; Verbosity: Verbosity; DataClassification: DataClassification; TelemetryScope: TelemetryScope; CustomDimensions: Dictionary of [Text, Text])
+ begin
+ Session.LogMessage(
+ EventId, Message, Verbosity, DataClassification, TelemetryScope, CustomDimensions);
+ end;
+
+ [EventSubscriber(ObjectType::Codeunit, Codeunit::"Telemetry Loggers", 'OnRegisterTelemetryLogger', '', true, true)]
+ local procedure RegisterSecond(var Sender: Codeunit "Telemetry Loggers")
+ var
+ Logger: Codeunit "Second Telemetry Logger";
+ begin
+ Sender.Register(Logger);
+ end;
+}
diff --git a/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.good.al b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.good.al
new file mode 100644
index 0000000..2cbe885
--- /dev/null
+++ b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.good.al
@@ -0,0 +1,18 @@
+codeunit 50408 "Sample Telemetry Logger" implements "Telemetry Logger"
+{
+ Access = Internal;
+
+ procedure LogMessage(EventId: Text; Message: Text; Verbosity: Verbosity; DataClassification: DataClassification; TelemetryScope: TelemetryScope; CustomDimensions: Dictionary of [Text, Text])
+ begin
+ Session.LogMessage(
+ EventId, Message, Verbosity, DataClassification, TelemetryScope, CustomDimensions);
+ end;
+
+ [EventSubscriber(ObjectType::Codeunit, Codeunit::"Telemetry Loggers", 'OnRegisterTelemetryLogger', '', true, true)]
+ local procedure OnRegisterTelemetryLogger(var Sender: Codeunit "Telemetry Loggers")
+ var
+ SampleTelemetryLogger: Codeunit "Sample Telemetry Logger";
+ begin
+ Sender.Register(SampleTelemetryLogger);
+ end;
+}
diff --git a/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md
new file mode 100644
index 0000000..d282dc4
--- /dev/null
+++ b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md
@@ -0,0 +1,26 @@
+---
+bc-version: [18..]
+domain: telemetry
+keywords: [telemetry-logger, interface, register, publisher, featuretelemetry, onregistertelemetrylogger]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Register exactly one Telemetry Logger implementation per publisher
+
+## Description
+
+The `Telemetry` and `Feature Telemetry` codeunits reach an extension publisher's telemetry through an implementation of the `"Telemetry Logger"` interface registered with `"Telemetry Loggers".OnRegisterTelemetryLogger`. The platform requires exactly one registration per app publisher. No registration prevents the module from working as expected; multiple registrations make the destination ambiguous and produce platform error telemetry.
+
+## Best Practice
+
+Place one internal logger implementation in one app for the publisher, forward its `LogMessage` method to `Session.LogMessage`, and register it from one event subscriber. Companion apps with the same publisher reuse that registration instead of each adding another. Evaluate absence only with repository or app-family context; a single-file diff cannot prove that no logger exists elsewhere.
+
+See sample: [`register-one-telemetry-logger-per-publisher.good.al`](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`](register-one-telemetry-logger-per-publisher.bad.al).
diff --git a/microsoft/knowledge/style/telemetry-event-id-stable-unique.bad.al b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.bad.al
similarity index 89%
rename from microsoft/knowledge/style/telemetry-event-id-stable-unique.bad.al
rename to microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.bad.al
index be60f4b..c9615be 100644
--- a/microsoft/knowledge/style/telemetry-event-id-stable-unique.bad.al
+++ b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.bad.al
@@ -1,4 +1,4 @@
-codeunit 50260 "Sample Telemetry Id Bad"
+codeunit 50260 "Telemetry Event Id Bad"
{
procedure LogCustomerProcessed(var Customer: Record Customer)
begin
diff --git a/microsoft/knowledge/style/telemetry-event-id-stable-unique.good.al b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.good.al
similarity index 88%
rename from microsoft/knowledge/style/telemetry-event-id-stable-unique.good.al
rename to microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.good.al
index 4491e31..dcdf6ae 100644
--- a/microsoft/knowledge/style/telemetry-event-id-stable-unique.good.al
+++ b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.good.al
@@ -1,4 +1,4 @@
-codeunit 50261 "Sample Telemetry Id Good"
+codeunit 50261 "Telemetry Event Id Good"
{
procedure LogCustomerProcessed(var Customer: Record Customer)
begin
diff --git a/microsoft/knowledge/style/telemetry-event-id-stable-unique.md b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.md
similarity index 89%
rename from microsoft/knowledge/style/telemetry-event-id-stable-unique.md
rename to microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.md
index da00af3..4850db5 100644
--- a/microsoft/knowledge/style/telemetry-event-id-stable-unique.md
+++ b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.md
@@ -1,6 +1,6 @@
---
-bc-version: [all]
-domain: style
+bc-version: [17..]
+domain: telemetry
keywords: [telemetry, logmessage, event-id, sessionlogmessage, observability]
technologies: [al]
countries: [w1]
@@ -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.bad.al b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.bad.al
new file mode 100644
index 0000000..26b0ee4
--- /dev/null
+++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.bad.al
@@ -0,0 +1,18 @@
+codeunit 50409 "Test AssertError Bad"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure BlankNameIsRejectedWithSpecificError()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.Init();
+ Customer.Name := '';
+
+ // Bare asserterror: passes if ANY error is raised. A relation error,
+ // a permission error, or a typo elsewhere would all satisfy it โ so
+ // this never proves the blank-name guard is the thing that fired.
+ asserterror Customer.TestField(Name);
+ end;
+}
diff --git a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.good.al b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.good.al
new file mode 100644
index 0000000..fcbcfe6
--- /dev/null
+++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.good.al
@@ -0,0 +1,26 @@
+codeunit 50408 "Test AssertError Good"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure BlankNameIsRejectedWithSpecificError()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.Init();
+ Customer.Name := '';
+
+ // [WHEN] a mandatory field is blank
+ asserterror Customer.TestField(Name);
+
+ // [THEN] verify the SPECIFIC failure through a reusable Library helper
+ // instead of hardcoding the localized message and the 'TestField' code.
+ // ExpectedTestFieldError centralizes that knowledge, so the test keeps
+ // working when the caption or code changes; FieldCaption avoids pinning
+ // the field name as a literal.
+ Assert.ExpectedTestFieldError(Customer.FieldCaption(Name), '');
+ end;
+
+ var
+ Assert: Codeunit "Library Assert";
+}
diff --git a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md
new file mode 100644
index 0000000..4560a18
--- /dev/null
+++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [asserterror, expectederror, expectederrorcode, negative-test, error-code]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Pin asserterror to a specific error with ExpectedError and ExpectedErrorCode
+
+## Description
+
+`asserterror` passes when the guarded statement raises any error at all. That is too permissive for a negative test: a typo, a missing setup record, or a permission failure all raise errors, so a bare `asserterror` can go green while never exercising the rule it claims to verify โ false confidence that the validation works. Constrain it. `Assert.ExpectedError(text)` checks the message of the error that was actually raised, and `Assert.ExpectedErrorCode(code)` checks its error code. Together they assert that the specific failure occurred, turning "something went wrong" into "the right thing went wrong for the right reason".
+
+## Best Practice
+
+Follow every `asserterror` with a verification of the error it expects, and prefer the reusable `Library Assert` helpers over hardcoded literals. For a mandatory-field check, `Assert.ExpectedTestFieldError(FieldCaption, ExpectedValue)` encapsulates both the message and the `TestField` code, so the test survives caption or code changes and does not repeat that knowledge in every method. For other errors, pair `Assert.ExpectedError` with a stable substring โ ideally a shared `Label`, not an inline sentence โ and, where known, `Assert.ExpectedErrorCode`. When a needed check is missing from the shared library, extend `Library Assert` (or your own assert library) with a helper rather than hardcoding message text and codes across tests; matching on a code or an invariant fragment keeps the test from going blind to the wrong error when a caption is localized.
+
+See sample: [`asserterror-needs-expectederror-and-code.good.al`](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`](asserterror-needs-expectederror-and-code.bad.al).
diff --git a/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.bad.al b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.bad.al
new file mode 100644
index 0000000..46cfed7
--- /dev/null
+++ b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.bad.al
@@ -0,0 +1,26 @@
+codeunit 50483 "Protected Setup Action Bad"
+{
+ trigger OnRun()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.Init();
+ Customer."No." := 'SUPER-INSERT';
+ Customer.Insert();
+ end;
+}
+
+codeunit 50484 "Permission Test Bad"
+{
+ Subtype = Test;
+ TestPermissions = Disabled;
+
+ [Test]
+ procedure LimitedUserCannotRunSetup()
+ var
+ SetupAction: Codeunit "Protected Setup Action Bad";
+ begin
+ // Disabled runs as SUPER; no limited-user boundary is exercised.
+ asserterror SetupAction.Run();
+ end;
+}
diff --git a/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.good.al b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.good.al
new file mode 100644
index 0000000..315ce6a
--- /dev/null
+++ b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.good.al
@@ -0,0 +1,43 @@
+permissionset 50480 "LIMITED USER"
+{
+ Assignable = false;
+ Permissions =
+ tabledata Customer = R,
+ codeunit "Protected Setup Action Test" = X;
+}
+
+codeunit 50481 "Protected Setup Action Test"
+{
+ trigger OnRun()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.Init();
+ Customer."No." := 'NO-INSERT';
+ Customer.Insert();
+ end;
+}
+
+codeunit 50482 "Permission Test Good"
+{
+ Subtype = Test;
+ TestPermissions = Restrictive;
+
+ [Test]
+ procedure LimitedUserCannotRunSetup()
+ var
+ PermissionsMock: Codeunit "Permissions Mock";
+ SetupAction: Codeunit "Protected Setup Action Test";
+ begin
+ PermissionsMock.Start();
+ PermissionsMock.SetExactPermissionSet('LIMITED USER');
+
+ asserterror SetupAction.Run();
+ Assert.ExpectedError('permission');
+
+ PermissionsMock.Stop();
+ end;
+
+ var
+ Assert: Codeunit "Library Assert";
+}
diff --git a/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md
new file mode 100644
index 0000000..1722578
--- /dev/null
+++ b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [testpermissions, restrictive, disabled, permissions-mock, lower-permissions, super, permission-test, false-positive]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Permission tests must actually lower the execution context
+
+## Description
+
+`TestPermissions` describes how a test runner should establish the permission context; the enum value does not itself assign the business permission set being tested. `Restrictive` is the default and starts from D365 Full Access, requiring the test to lower permissions. `Disabled` leaves the test running as `SUPER`. A test that expects access to be denied while still running with either broad context can pass or fail for the wrong reason and never exercise the intended boundary.
+
+What matters is the effective permission context at the moment the protected operation runs, not which permission set object the test names. A test may establish that context through a composed role that includes the permission set under test rather than applying that set directly โ that mirrors how the permission set actually reaches a user in production, where roles are assigned and permission sets are included. Such a test is adequate when it proves the boundary it claims: for an indirect (lowercase `imd`) grant, asserting `WritePermission()` is false before invoking the mediating codeunit shows that no direct access was granted and that the subsequent write succeeded only through code.
+
+## Best Practice
+
+Use `TestPermissions::Restrictive` for a permission-sensitive test and lower the current test user with the test framework's `"Permissions Mock"` or `"Library - Lower Permissions"` before invoking the protected operation. Assign 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`](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`](permission-tests-must-lower-the-execution-context.bad.al).
diff --git a/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.bad.al b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.bad.al
new file mode 100644
index 0000000..3d4bbc3
--- /dev/null
+++ b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.bad.al
@@ -0,0 +1,22 @@
+codeunit 50452 "Isolated Test Runner Bad"
+{
+ Subtype = TestRunner;
+ TestIsolation = Disabled;
+}
+
+codeunit 50453 "Committed Write Test Bad"
+{
+ Subtype = Test;
+
+ [Test]
+ [TransactionModel(TransactionModel::AutoCommit)]
+ procedure TestCommittedWrite()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.Init();
+ Customer."No." := 'PERSISTS';
+ Customer.Insert();
+ Commit();
+ end;
+}
diff --git a/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.good.al b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.good.al
new file mode 100644
index 0000000..6bf4c8d
--- /dev/null
+++ b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.good.al
@@ -0,0 +1,22 @@
+codeunit 50450 "Isolated Test Runner Good"
+{
+ Subtype = TestRunner;
+ TestIsolation = Codeunit;
+}
+
+codeunit 50451 "Committed Write Test Good"
+{
+ Subtype = Test;
+
+ [Test]
+ [TransactionModel(TransactionModel::AutoCommit)]
+ procedure TestCommittedWrite()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.Init();
+ Customer."No." := 'ISOLATED';
+ Customer.Insert();
+ Commit();
+ end;
+}
diff --git a/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md
new file mode 100644
index 0000000..f296d51
--- /dev/null
+++ b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [testisolation, testrunner, autocommit, commit, rollback, test-order, database-state]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Configure TestIsolation on the test runner
+
+## Description
+
+`TestIsolation` is a property of a `Subtype = TestRunner` codeunit, not of the test codeunit being executed. Its default is `Disabled`. `Codeunit` rolls back database changes after each test codeunit and `Function` after each test method, including changes that the code under test explicitly committed. Without runner isolation, an `AutoCommit` test can leave data behind and make later tests order-dependent.
+
+## Best Practice
+
+Run independent suites with `TestIsolation = Codeunit` or `Function`, choosing the narrowest boundary the runner supports. Pair this with the appropriate method-level `TransactionModel`: `AutoCommit` permits code under test to commit, while runner isolation still restores the database afterward. Keep isolation disabled only for an intentionally shared-state suite whose ordering and cleanup are explicit.
+
+See sample: [`testisolation-belongs-on-the-test-runner.good.al`](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`](testisolation-belongs-on-the-test-runner.bad.al).
diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al
index c30ae3b..4b5a26d 100644
--- a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al
+++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.bad.al
@@ -5,6 +5,23 @@ codeunit 50154 "Test Sample TransModel Bad"
[Test]
[TransactionModel(TransactionModel::AutoRollback)]
procedure TestPostingRoutineAutoRollback()
+ var
+ PostingRoutine: Codeunit "Posting Routine Commit Bad";
begin
+ // Runtime error: AutoRollback forbids the Commit reached below.
+ PostingRoutine.PostCustomer();
+ end;
+}
+
+codeunit 50156 "Posting Routine Commit Bad"
+{
+ procedure PostCustomer()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.Init();
+ Customer."No." := 'T-BADCOMMIT';
+ Customer.Insert(true);
+ Commit();
end;
}
diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al
index f977a94..7a19a61 100644
--- a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al
+++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.good.al
@@ -16,6 +16,22 @@ codeunit 50153 "Test Sample TransModel Good"
[Test]
[TransactionModel(TransactionModel::AutoCommit)]
procedure TestLogicThatCommitsInternally()
+ var
+ PostingRoutine: Codeunit "Posting Routine With Commit";
begin
+ PostingRoutine.PostCustomer();
+ end;
+}
+
+codeunit 50155 "Posting Routine With Commit"
+{
+ procedure PostCustomer()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.Init();
+ Customer."No." := 'T-COMMIT';
+ Customer.Insert(true);
+ Commit();
end;
}
diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md
index 084fccd..c9c159a 100644
--- a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md
+++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md
@@ -15,12 +15,12 @@ application-area: [all]
## Best Practice
-Default to `AutoRollback`: it opens a write transaction at the start of the test, runs the test body, and rolls back at the end, leaving the database in its original state. Pick `AutoCommit` only when the code under test genuinely calls `Commit` โ posting routines, job-queue handlers, integration flows โ and pair that test's codeunit with a `TestIsolation`-enabled test runner so committed changes are reverted at a higher scope. Pick `None` only for read-only tests or tests that drive UI code without writing from the test method itself, for example tests that validate calculation formulas or read-only projections.
+Default to `AutoRollback`: it opens a write transaction at the start of the test, runs the test body, and rolls back at the end, leaving the database in its original state. Pick `AutoCommit` only when the code under test genuinely calls `Commit` โ posting routines, job-queue handlers, integration flows โ and make the test exercise that commit path. Pair the test codeunit with a `TestIsolation`-enabled test runner so committed changes are reverted at a higher scope. Pick `None` only for read-only tests or tests that drive UI code without writing from the test method itself.
-See sample: `transactionmodel-attribute-governs-test-transactions.good.al`.
+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.
-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).
diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al b/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al
new file mode 100644
index 0000000..47743e0
--- /dev/null
+++ b/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al
@@ -0,0 +1,75 @@
+codeunit 50401 "Test UI Handler Proof Bad"
+{
+ Subtype = Test;
+
+ [Test]
+ [HandlerFunctions('CustomerCardHandler')]
+ procedure PreSetBooleanDoesNotProveCustomerCardResult()
+ var
+ Customer: Record Customer;
+ begin
+ LibrarySales.CreateCustomer(Customer);
+ ActionSucceeded := true;
+
+ Page.RunModal(Page::"Customer Card", Customer);
+
+ // This only proves a value assigned before the action stayed true.
+ Assert.IsTrue(ActionSucceeded, 'The customer card action failed.');
+ end;
+
+ [Test]
+ [HandlerFunctions('CustomerCardHandler')]
+ procedure MissingMessageHandlerFailsAtRuntime()
+ var
+ Customer: Record Customer;
+ begin
+ LibrarySales.CreateCustomer(Customer);
+
+ Page.RunModal(Page::"Customer Card", Customer);
+ Message('Customer card closed.');
+ end;
+
+ [Test]
+ [HandlerFunctions('CustomerCardHandler,UnusedConfirmHandler')]
+ procedure UnreachedListedHandlerFailsAtRuntime()
+ var
+ Customer: Record Customer;
+ begin
+ LibrarySales.CreateCustomer(Customer);
+
+ Page.RunModal(Page::"Customer Card", Customer);
+ end;
+
+ [Test]
+ [HandlerFunctions('CustomerCardHandler,MandatoryNotificationHandler')]
+ procedure UnreachedNonoptionalNotificationHandlerFailsAtRuntime()
+ var
+ Customer: Record Customer;
+ begin
+ LibrarySales.CreateCustomer(Customer);
+
+ Page.RunModal(Page::"Customer Card", Customer);
+ end;
+
+ [ModalPageHandler]
+ procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card")
+ begin
+ end;
+
+ [ConfirmHandler]
+ procedure UnusedConfirmHandler(Question: Text[1024]; var Reply: Boolean)
+ begin
+ Reply := true;
+ end;
+
+ [SendNotificationHandler]
+ procedure MandatoryNotificationHandler(var TheNotification: Notification): Boolean
+ begin
+ exit(true);
+ end;
+
+ var
+ Assert: Codeunit "Library Assert";
+ LibrarySales: Codeunit "Library - Sales";
+ ActionSucceeded: Boolean;
+}
diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.good.al b/microsoft/knowledge/testing/ui-handlers-in-tests.good.al
new file mode 100644
index 0000000..753873f
--- /dev/null
+++ b/microsoft/knowledge/testing/ui-handlers-in-tests.good.al
@@ -0,0 +1,49 @@
+codeunit 50400 "Test UI Handler Capture Good"
+{
+ Subtype = Test;
+
+ [Test]
+ [HandlerFunctions('CustomerCardHandler')]
+ procedure CustomerCardShowsSelectedCustomer()
+ var
+ Customer: Record Customer;
+ begin
+ LibrarySales.CreateCustomer(Customer);
+ CapturedCustomerNo := '';
+
+ Page.RunModal(Page::"Customer Card", Customer);
+
+ Assert.AreEqual(Customer."No.", CapturedCustomerNo, 'The customer card opened for the wrong customer.');
+ end;
+
+ [Test]
+ [HandlerFunctions('CustomerCardHandler,CreditLimitNotificationHandler')]
+ procedure CustomerCardOpensForCustomerWithinCreditLimit()
+ var
+ Customer: Record Customer;
+ begin
+ LibrarySales.CreateCustomer(Customer);
+ CapturedCustomerNo := '';
+
+ Page.RunModal(Page::"Customer Card", Customer);
+
+ Assert.AreEqual(Customer."No.", CapturedCustomerNo, 'The customer card opened for the wrong customer.');
+ end;
+
+ [ModalPageHandler]
+ procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card")
+ begin
+ CapturedCustomerNo := CustomerCard."No.".Value();
+ end;
+
+ [SendNotificationHandler(true)]
+ procedure CreditLimitNotificationHandler(var CreditLimitNotification: Notification): Boolean
+ begin
+ exit(true);
+ end;
+
+ var
+ Assert: Codeunit "Library Assert";
+ LibrarySales: Codeunit "Library - Sales";
+ CapturedCustomerNo: Code[20];
+}
diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.md b/microsoft/knowledge/testing/ui-handlers-in-tests.md
new file mode 100644
index 0000000..9ca1ce1
--- /dev/null
+++ b/microsoft/knowledge/testing/ui-handlers-in-tests.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [handler, handlerfunctions, confirm, message, notification, optional-handler, enqueue, capture, runmodal, unhandled-ui]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Wire UI handlers and verify meaningful outcomes
+
+## Description
+
+A test runs headless, so every UI call on the executed path must be intercepted by a matching handler named in `[HandlerFunctions(...)]`. The list is a two-sided contract: an unhandled UI call aborts the test, while Microsoft documents that [every nonoptional listed handler must execute at least once](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/attributes/devenv-handlerfunctions-attribute#remarks) or the test fails.
+
+Optionality is declared, not inferred. `SendNotificationHandler` and `RecallNotificationHandler` accept a `HandlerIsOptional` argument, so `[SendNotificationHandler(true)]` may stay listed on a run that never raises the notification, while the same attribute written without that argument is nonoptional like every other handler type. Notifications are conditional by nature, so an optional notification handler is listed precisely because the scenario may or may not reach it.
+
+Beyond that wiring guarantee, the test must verify the behavior it cares about. The appropriate pattern depends on the contract: a handler can capture concrete page state or a result and the test can assert that semantic postcondition after `RunModal`; assertions inside a handler are also supported. Queue/enqueue/dequeue and `LibraryVariableStorage.AssertEmpty` are useful when interaction order, count, text, replies, or a scripted sequence is itself part of the contract, but they are not mandatory for every handler.
+
+## Best Practice
+
+List 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`](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`](ui-handlers-in-tests.bad.al).
diff --git a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.bad.al b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.bad.al
new file mode 100644
index 0000000..cf7805a
--- /dev/null
+++ b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.bad.al
@@ -0,0 +1,25 @@
+codeunit 50411 "Test Library Fixtures Bad"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure OrderUsesHandRolledFixtures()
+ var
+ Customer: Record Customer;
+ SalesHeader: Record "Sales Header";
+ begin
+ // Hand-rolled customer: a chosen "No." with no number-series entry and
+ // none of the mandatory fields a real customer carries. Bypasses the
+ // setup production code assumes and breaks when the schema adds a
+ // required field this test does not set.
+ Customer.Init();
+ Customer."No." := 'X';
+ Customer.Insert();
+
+ SalesHeader.Init();
+ SalesHeader."Document Type" := SalesHeader."Document Type"::Order;
+ SalesHeader."No." := 'SO-X';
+ SalesHeader.Validate("Sell-to Customer No.", Customer."No.");
+ SalesHeader.Insert(true);
+ end;
+}
diff --git a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.good.al b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.good.al
new file mode 100644
index 0000000..66d4b32
--- /dev/null
+++ b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.good.al
@@ -0,0 +1,28 @@
+codeunit 50410 "Test Library Fixtures Good"
+{
+ Subtype = Test;
+
+ [Test]
+ procedure OrderUsesLibraryCreatedFixtures()
+ var
+ Customer: Record Customer;
+ Item: Record Item;
+ SalesHeader: Record "Sales Header";
+ SalesLine: Record "Sales Line";
+ begin
+ // Library codeunits create valid parents: number series, mandatory
+ // fields and table relations are all handled for you.
+ LibrarySales.CreateCustomer(Customer);
+ LibraryInventory.CreateItem(Item);
+ LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
+ LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", LibraryRandom.RandInt(10));
+
+ Assert.AreEqual(Customer."No.", SalesHeader."Sell-to Customer No.", 'Header should use the created customer.');
+ end;
+
+ var
+ Assert: Codeunit "Library Assert";
+ LibrarySales: Codeunit "Library - Sales";
+ LibraryInventory: Codeunit "Library - Inventory";
+ LibraryRandom: Codeunit "Library - Random";
+}
diff --git a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md
new file mode 100644
index 0000000..9c8f092
--- /dev/null
+++ b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: testing
+keywords: [library-codeunits, fixtures, test-data, number-series, prerequisite]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Build fixtures with the test Library codeunits, not hand-rolled Init/Insert
+
+## Description
+
+BC ships a layer of test Library codeunits โ `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom` and many more โ whose job is to create valid records. `CreateCustomer` assigns a number from the customer number series, fills the mandatory fields, and satisfies the table relations the platform enforces; `CreateItem` does the same for items. Hand-rolling `Customer.Init`/`Customer.Insert` with invented values skips the number series and any field a future app version adds as mandatory, so the fixture is invalid the moment it is created and rots silently as the schema evolves. The library codeunits also encode fixture *ordering*: because a `TableRelation` field is checked on `Validate` and `Insert(true)`, every parent a foreign key points to must already exist when the dependent record is built. Assemble fixtures top-down โ customer and item before the sales line that references them โ or the relation check aborts the test at runtime with a data error rather than an assertion. Prefer the Library codeunits for prerequisite data: they encode the setup the platform requires and are maintained alongside the base app.
+
+## Best Practice
+
+Reach for the matching Library codeunit before writing manual record setup: `LibrarySales.CreateCustomer`, `LibrarySales.CreateSalesHeader`/`CreateSalesLine`, `LibraryInventory.CreateItem`, `LibraryERM.CreateGLAccount`, and `LibraryRandom.RandInt`/`RandDec` for values. Create the prerequisite parents first and reference their primary keys from dependent records, and `Validate` the foreign-key field so the `TableRelation` โ and any field-validation logic โ runs exactly as it would in production. Pass the records they return into the code under test. The fixtures stay valid across upgrades because the library โ not your test โ owns the knowledge of what a well-formed record requires.
+
+See sample: [`use-library-codeunits-for-test-fixtures.good.al`](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`](use-library-codeunits-for-test-fixtures.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
new file mode 100644
index 0000000..00dc9b8
--- /dev/null
+++ b/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md
@@ -0,0 +1,30 @@
+---
+bc-version: [24..]
+domain: ui
+keywords: [tooltip, page-field, source-field, inheritance, aa0218, false-positive]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# A page field bound to a table field inherits that field's ToolTip
+
+## Description
+
+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 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
+
+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.good.al b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.good.al
new file mode 100644
index 0000000..077bcdf
--- /dev/null
+++ b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.good.al
@@ -0,0 +1,38 @@
+page 50210 "UI Sample Caption Case"
+{
+ PageType = List;
+ ApplicationArea = All;
+ SourceTable = "Sales Line";
+
+ layout
+ {
+ area(Content)
+ {
+ repeater(Lines)
+ {
+ field("Document No."; Rec."Document No.")
+ {
+ ToolTip = 'Specifies the document number.';
+ }
+ }
+ }
+ }
+
+ actions
+ {
+ area(Processing)
+ {
+ action(ShowSourceDocument)
+ {
+ Caption = 'Show source document';
+ Image = ViewSourceDocumentLine;
+ ToolTip = 'Open the related source document.';
+
+ trigger OnAction()
+ begin
+ Message('%1', Rec."Document No.");
+ end;
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md
new file mode 100644
index 0000000..648a138
--- /dev/null
+++ b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [caption, capitalization, sentence-case, title-case, action, noun-phrase, false-positive]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Sentence-phrase captions use sentence case, not title case
+
+## Description
+
+Business Central caption capitalization depends on whether the caption reads as a **noun phrase** or a **sentence/verb phrase**. Following the Microsoft writing-style guideline, a caption that reads as an imperative sentence โ most action captions, such as `'Show source document'`, `'Post and print'`, or `'Copy from last inspection'` โ uses **sentence case**: only the first word and any proper nouns are capitalized. Title case (`'Show Source Document'`) is the older convention and is not required for these captions.
+
+Noun-phrase captions (object names, field labels such as `'Source Document No.'`) follow their own capitalization; that is a separate case and is not what this article covers. Reviewers sometimes see a lower-cased word in an action caption (`'Show source document'`) and flag it as inconsistent title case, but a sentence-phrase action caption is correct as written.
+
+## Best Practice
+
+For an action `Caption` that reads as a sentence or verb phrase, capitalize only the first word and proper nouns (sentence case). Do not require every significant word to be capitalized. Before flagging a caption as "should be title case", confirm it is a noun phrase; leave imperative/sentence-phrase action captions in sentence case.
+
+See sample: [`caption-capitalization-noun-phrase-vs-sentence-phrase.good.al`](caption-capitalization-noun-phrase-vs-sentence-phrase.good.al).
+
+## Anti Pattern
+
+Reporting a sentence-case action caption such as `'Show source document'` as a style defect and recommending title case (`'Show Source Document'`), or calling it inconsistent with BC conventions. Sentence case is the current guideline for sentence-phrase captions.
diff --git a/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.bad.js b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.bad.js
new file mode 100644
index 0000000..d36c363
--- /dev/null
+++ b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.bad.js
@@ -0,0 +1,3 @@
+function loadPackagedTemplate(url) {
+ return $.get(url).done(renderTemplate);
+}
diff --git a/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.good.js b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.good.js
new file mode 100644
index 0000000..781c23d
--- /dev/null
+++ b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.good.js
@@ -0,0 +1,8 @@
+function loadPackagedTemplate(url) {
+ return $.ajax({
+ url: url,
+ xhrFields: {
+ withCredentials: true
+ }
+ }).done(renderTemplate);
+}
diff --git a/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md
new file mode 100644
index 0000000..f0b5314
--- /dev/null
+++ b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [control-add-in, packaged-resource, ajax, withcredentials, xhrfields, jquery]
+technologies: [javascript]
+countries: [w1]
+application-area: [all]
+---
+
+# Load packaged control add-in resources with credentialed AJAX
+
+## Description
+
+JavaScript in a Business Central control add-in can load a static resource from its extension package with AJAX, but the request needs the Business Central context and cookies. Set `xhrFields.withCredentials = true`; shorthand calls such as `$.get` omit that setting and can work during development yet fail in production.
+
+## Best Practice
+
+Use an AJAX form that explicitly enables `withCredentials` whenever a control add-in requests a packaged static resource. Keep this rule scoped to resources served from the add-in package; it is not generic advice to attach credentials to arbitrary external requests.
+
+See sample: [`control-addin-package-resource-ajax-needs-withcredentials.good.js`](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`](control-addin-package-resource-ajax-needs-withcredentials.bad.js).
+
+## Source
+
+[Control add-in object: Loading static resources using AJAX requests](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-control-addin-object#loading-static-resources-using-ajax-requests).
diff --git a/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.bad.js b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.bad.js
new file mode 100644
index 0000000..9bd6ebf
--- /dev/null
+++ b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.bad.js
@@ -0,0 +1,8 @@
+function startSendingRows(rows) {
+ window.setInterval(() => {
+ Microsoft.Dynamics.NAV.InvokeExtensibilityMethod(
+ "StoreRows",
+ [JSON.stringify(rows)],
+ false);
+ }, 100);
+}
diff --git a/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.good.js b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.good.js
new file mode 100644
index 0000000..d6815a8
--- /dev/null
+++ b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.good.js
@@ -0,0 +1,74 @@
+const pendingChunks = [];
+let callInProgress = false;
+let transferHalted = false;
+
+function sendRows(rows, maxArgumentsBytes) {
+ if (transferHalted)
+ throw new Error("Retry or discard the failed chunk before sending more rows.");
+
+ const encoder = new TextEncoder();
+ const chunks = [];
+ let chunk = [];
+ const argumentBytes = (payload) =>
+ encoder.encode(JSON.stringify([payload])).length;
+
+ for (const row of rows) {
+ if (argumentBytes(JSON.stringify([row])) > maxArgumentsBytes)
+ throw new Error("A row exceeds the configured payload limit.");
+
+ const candidate = JSON.stringify([...chunk, row]);
+
+ if (argumentBytes(candidate) <= maxArgumentsBytes) {
+ chunk.push(row);
+ continue;
+ }
+
+ chunks.push(JSON.stringify(chunk));
+ chunk = [row];
+ }
+
+ if (chunk.length > 0)
+ chunks.push(JSON.stringify(chunk));
+
+ pendingChunks.push(...chunks);
+ sendNextChunk();
+}
+
+function sendNextChunk() {
+ if (callInProgress || pendingChunks.length === 0)
+ return;
+
+ callInProgress = true;
+ const payload = pendingChunks[0];
+ Microsoft.Dynamics.NAV.InvokeExtensibilityMethod(
+ "StoreRows",
+ [payload],
+ false,
+ () => {
+ pendingChunks.shift();
+ callInProgress = false;
+ sendNextChunk();
+ },
+ () => {
+ callInProgress = false;
+ transferHalted = true;
+ showTransferError();
+ });
+}
+
+function retryFailedChunk() {
+ if (!transferHalted)
+ return;
+
+ transferHalted = false;
+ sendNextChunk();
+}
+
+function discardFailedChunk() {
+ if (!transferHalted)
+ return;
+
+ pendingChunks.shift();
+ transferHalted = false;
+ sendNextChunk();
+}
diff --git a/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.md b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.md
new file mode 100644
index 0000000..92d29b5
--- /dev/null
+++ b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.md
@@ -0,0 +1,30 @@
+---
+bc-version: [20..]
+domain: ui
+keywords: [control-add-in, invokeextensibilitymethod, success-callback, throttling, payload, reduced-functionality]
+technologies: [javascript]
+countries: [w1]
+application-area: [all]
+---
+
+# Serialize control add-in AL calls and keep payloads small
+
+## Description
+
+`InvokeExtensibilityMethod` crosses from a control add-in into the Business Central service. Repeated calls that outpace AL execution fill the communication channel, trigger reduced-functionality warnings, and can be queued, throttled, or rejected; an oversized single payload can also be rejected immediately. The success and error callbacks exist so the add-in can bound this traffic.
+
+## Best Practice
+
+Send byte-bounded chunks and invoke the next AL event only from the previous call's completion callback. Handle the error callback and stop until the caller explicitly retries or discards the failed chunk. There is no universal safe threshold, so measure the serialized argument array, reserve transport headroom below the server's `ClientServicesMaxUploadSize`, and reject an individual item that exceeds the configured budget.
+
+See sample: [`control-addin-throttle-al-calls-and-payload-size.good.js`](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`](control-addin-throttle-al-calls-and-payload-size.bad.js).
+
+## Source
+
+[Control add-in performance best practices](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-control-addin-bestpractices), [InvokeExtensibilityMethod](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods/devenv-invokeextensibility-method), and [control add-in resiliency](https://learn.microsoft.com/dynamics365/business-central/across-controladdin-resiliency).
diff --git a/community/knowledge/ui/default-descending-sort-on-historical-pages.bad.al b/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.bad.al
similarity index 100%
rename from community/knowledge/ui/default-descending-sort-on-historical-pages.bad.al
rename to microsoft/knowledge/ui/default-descending-sort-on-historical-pages.bad.al
diff --git a/community/knowledge/ui/default-descending-sort-on-historical-pages.good.al b/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.good.al
similarity index 100%
rename from community/knowledge/ui/default-descending-sort-on-historical-pages.good.al
rename to microsoft/knowledge/ui/default-descending-sort-on-historical-pages.good.al
diff --git a/community/knowledge/ui/default-descending-sort-on-historical-pages.md b/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.md
similarity index 81%
rename from community/knowledge/ui/default-descending-sort-on-historical-pages.md
rename to microsoft/knowledge/ui/default-descending-sort-on-historical-pages.md
index 185e56f..a3607c3 100644
--- a/community/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/factbox-design.md b/microsoft/knowledge/ui/factbox-design.md
new file mode 100644
index 0000000..b4f0544
--- /dev/null
+++ b/microsoft/knowledge/ui/factbox-design.md
@@ -0,0 +1,20 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [factbox, subpagelink, listpart, cardpart, page-part, related-information, flowfield-sift]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+# Filter ListPart FactBoxes With SubPageLink To The Parent Record
+
+> Contributions welcome โ open a PR to refine or extend this article.
+
+## Description
+A FactBox is a page `part` that surfaces related data beside the main record so users avoid navigating away. Every FactBox runs a database query as its host page loads, so an unfiltered one is a hidden performance tax paid on every page open. The remedial trap: a `ListPart` FactBox with no `SubPageLink` does not show "the related rows" โ it loads and pages through the entire source table, because nothing ties it to the host record. This makes correct `SubPageLink` linkage, not visual layout, the load-bearing design decision.
+
+## Best Practice
+Give every `ListPart` FactBox a `SubPageLink` that maps a field on the part's source table to a `field()` of the host record (for example `SubPageLink = "Document No." = field("No.")`), so it returns only rows belonging to the current record. Prefer a `CardPart` when you only need summary figures (balance, availability, status) โ it reads a single record and avoids list overhead entirely. When a FactBox shows FlowFields, ensure the calculated total is backed by a SIFT key (`MaintainSIFTIndex`) so the sum is read from the index rather than aggregated row-by-row on each load. Keep FactBox count modest and avoid heavy `OnAfterGetRecord` logic in the part.
+
+## Anti Pattern
+Adding a `ListPart` FactBox without a `SubPageLink`, expecting it to "just show related lines." The consequence is a full-table scan on every page load that grows with the dataset and is felt worst on list pages, where the FactBox re-queries on each row selection. Reviewer signal: any `part(...)` referencing a list-type page part where the `SubPageLink` property is absent, or a FactBox FlowField filtered on non-indexed fields. A second smell is duplicating data already on the page or stacking many FactBoxes, which multiplies queries for little context gain.
diff --git a/microsoft/knowledge/ui/fasttab-field-importance.md b/microsoft/knowledge/ui/fasttab-field-importance.md
new file mode 100644
index 0000000..f7de6dd
--- /dev/null
+++ b/microsoft/knowledge/ui/fasttab-field-importance.md
@@ -0,0 +1,18 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [importance, promoted, additional, fasttab, show-more, summary-line, progressive-disclosure, field-visibility]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+# Set Field Importance To Drive FastTab Progressive Disclosure
+
+## Description
+A FastTab field's `Importance` property controls whether the field is visible immediately, hidden behind "Show more", or surfaced on the collapsed FastTab header summary line. The three values are `Standard` (the default, shown in the expanded FastTab), `Promoted` (also rendered on the FastTab header when the tab is collapsed), and `Additional` (hidden until the user clicks "Show more"). Misusing these values either clutters the summary line or buries fields users need on every transaction, so reviewers should treat `Importance` as a deliberate layout decision rather than an afterthought.
+
+## Best Practice
+Promote only the small set of identifying fields per FastTab that users must read at a glance without expanding โ name, status, key amount โ so the collapsed header summary line stays scannable. Leave the everyday working fields at `Standard`, and push rarely-touched fields (legacy compatibility fields, system timestamps, seldom-changed configuration) to `Additional`. Note that field-level `Importance = Promoted` is unrelated to action promotion on the page action bar; it governs FastTab field visibility only. Do not rely on initial expand or collapse state, which you cannot set programmatically and which the platform may personalize per user โ design assuming any FastTab may be collapsed.
+
+## Anti Pattern
+Setting `Importance = Promoted` on most fields of a FastTab so "everything is important" defeats progressive disclosure: the collapsed summary line overflows and conveys nothing at a glance. The opposite failure is marking frequently edited fields `Additional`, forcing users to click "Show more" on every record. A detectable signal is a FastTab whose fields are nearly all `Promoted`, or a FastTab containing only `Additional` fields, which renders as an empty tab until expanded.
diff --git a/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-background-tasks.md b/microsoft/knowledge/ui/page-background-tasks.md
new file mode 100644
index 0000000..c4cccb8
--- /dev/null
+++ b/microsoft/knowledge/ui/page-background-tasks.md
@@ -0,0 +1,18 @@
+---
+bc-version: [15..]
+domain: ui
+keywords: [enqueuebackgroundtask, async-calculation, child-session, factbox, cue-tile, onaftergetcurrrecord, responsive-page, read-only]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+# Offload Slow Read-Only Page Calculations To Background Tasks
+
+## Description
+Pages that compute statistics, aggregates, or external lookups inline block the page from rendering until the calculation finishes, producing a visible freeze on FactBoxes, cue tiles, and calculated fields. Business Central provides page background tasks: `CurrPage.EnqueueBackgroundTask` runs a dedicated codeunit in a read-only child session and returns values via `OnPageBackgroundTaskCompleted`, so the page opens immediately and fills in computed values as they arrive. This matters because users should never wait on a calculation they may not need. The mechanism has specific rules that are easy to get wrong, which is why it warrants an explicit pattern.
+
+## Best Practice
+Move any noticeable read-only computation off the synchronous render path into a background task. Enqueue from `OnAfterGetCurrRecord` so the task is tied to the currently focused record, and pass small payloads through the `Dictionary of [Text, Text]` input/output, converting types with `Format` and `Evaluate`. Keep each task focused on one value or a small related set rather than one large task, and show a placeholder until results land. Because tasks auto-cancel when the page closes, the record changes, or a same-ID task is re-enqueued, always supply sensible defaults and handle the timeout path in `OnPageBackgroundTaskError` โ never let critical functionality depend on completion. For tests, drive the task synchronously with `RunPageBackgroundTask`.
+
+## Anti Pattern
+Enqueuing from `OnAfterGetRecord` on a list page fires the task for every row, and each cancels the instant the selection moves to the next row โ pure wasted child-session churn; a reviewer spots `EnqueueBackgroundTask` called from `OnAfterGetRecord` (or from `OnOpenPage`, where the record context is not yet stable). The other tell is a task codeunit attempting a database write or `Modify`: background tasks run read-only and the write fails at runtime. Inline heavy calculation directly in `OnAfterGetCurrRecord` with no task at all is the baseline smell โ it reintroduces the page freeze the feature exists to remove.
diff --git a/microsoft/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md b/microsoft/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md
new file mode 100644
index 0000000..b0e66f0
--- /dev/null
+++ b/microsoft/knowledge/ui/prefer-actionref-syntax-for-promoted-actions.md
@@ -0,0 +1,18 @@
+---
+bc-version: [21..]
+domain: ui
+keywords: [actionref, promoted-actions, area-promoted, promotedcategory, promotedonly, action-bar, legacy-syntax]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+# Promote Actions With The Modern `actionref` Syntax, Never The Legacy `Promoted` Properties
+
+## Description
+Business Central 2022 release wave 2 (v21) introduced the `area(Promoted)` block with `actionref` as the way to promote page actions, separating an action's definition from its promotion. The older approach set `Promoted`, `PromotedCategory`, `PromotedOnly`, and `PromotedIsBig` directly on each action. The two syntaxes cannot be mixed within a single page or page extension, and choosing the legacy one entangles definition with presentation, making the action bar harder to maintain and to extend.
+
+## Best Practice
+For new pages and page extensions, define actions in their normal `area`, then promote selected ones with `actionref` inside `area(Promoted)`, grouping them under explicit categories such as `Category_Process` and entity-named groups. This keeps each action defined once and referenced where it should appear, supports split buttons via `ShowAs`, and lets an extension promote a base action without redefining it. When extending a page, you may use modern syntax even if the base page used legacy properties (and vice versa) โ the no-mixing rule is per-object, not per-dependency-tree.
+
+## Anti Pattern
+Setting `Promoted = true` (with `PromotedCategory`, `PromotedOnly`, or `PromotedIsBig`) on actions in new code, or attempting to combine those properties with an `area(Promoted)` block in the same object โ the latter fails to compile. The reviewer signal is any `Promoted`-prefixed property on an action in a newly authored page or page extension; flag it and convert to `actionref` (VS Code offers an automated conversion). Note separately that once an action is promoted in a published app, removing the promotion is a breaking change (AS0031/AW0013), so promote conservatively rather than walking it back later.
diff --git a/microsoft/knowledge/ui/promoted-action-groups.md b/microsoft/knowledge/ui/promoted-action-groups.md
new file mode 100644
index 0000000..e2e8883
--- /dev/null
+++ b/microsoft/knowledge/ui/promoted-action-groups.md
@@ -0,0 +1,18 @@
+---
+bc-version: [21..]
+domain: ui
+keywords: [action-groups, area-promoted, actionref, showas, split-button, group-caption, navigate-group, entity-group]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+# Use Standard Promoted Action Group Names And Placements
+
+## Description
+Business Central ships a fixed vocabulary of promoted action groups, and users build muscle memory around where each kind of action lives. When you define `area(Promoted)` groups, reusing the standard caption and placement for a given action class makes the page feel native; inventing your own caption or putting an action in the wrong group forces every user to relearn your page. Frontier models tend to emit plausible-but-nonstandard captions (`Go To`, `Vendor Actions`, `Related`) instead of the established BC names, which is exactly what breaks cross-page consistency.
+
+## Best Practice
+Map each action to its conventional group and use the exact standard caption: `Home`/`Process` for data-modifying and workflow actions (entity/card/document pages use `Home`, lists and worksheets use `Process`); an entity-named group (`Customer`, `Item`, `Order`) for navigation tied to the current record (statistics, ledger entries, dimensions); `Navigate` for related pages that are useful regardless of the selected record; `Report` for printing and analysis; and the workflow groups `Posting`, `Release`, `Approve`, `Request Approval`, and `Prepare` for their respective document lifecycle actions. Standard guidance recommends `ShowAs = SplitButton` for `Posting` (Post / Post and Print / Preview) and `Release` (Release / Reopen), while the other common groups normally render as standard groups. Use a split button elsewhere only for closely related alternatives with an obvious primary action. The first enabled and visible action becomes the primary button, so place the expected default first and remember that extensions or personalization can reorder it. Within a common group keep the same action sequence you see on the matching base-app page (for example, mirror Sales Order for a sales document) so order stays predictable.
+
+## Anti Pattern
+Custom captions for what is really a standard group (`Vendor Actions` instead of the `Vendor` entity group, `Go To` instead of `Navigate`), posting or statistics actions dropped into the wrong group, or many tiny one-action groups that fragment the ribbon. The reviewer signal is an `area(Promoted)` block whose `group` captions do not match the base-application names for the same page type, or a split button whose actions are unrelated or lack an obvious primary operation.
diff --git a/microsoft/knowledge/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.bad.al b/microsoft/knowledge/ui/set-selection-filter-list-scope.bad.al
new file mode 100644
index 0000000..ff3ca99
--- /dev/null
+++ b/microsoft/knowledge/ui/set-selection-filter-list-scope.bad.al
@@ -0,0 +1,12 @@
+// Bad: SetSelectionFilter with cursor-only (no explicit multi-selection) produces
+// a primary key filter for just that one row. The codeunit receives only that row;
+// the rest of the visible list is silently skipped with no error raised.
+trigger OnAction()
+var
+ PriceListHeader: Record "Price List Header";
+ TempErrorMessage: Record "Error Message" temporary;
+ ProcessingCodeunit: Codeunit "My Batch Processor";
+begin
+ CurrPage.SetSelectionFilter(PriceListHeader);
+ ProcessingCodeunit.RunBatch(PriceListHeader, TempErrorMessage);
+end;
diff --git a/microsoft/knowledge/ui/set-selection-filter-list-scope.good.al b/microsoft/knowledge/ui/set-selection-filter-list-scope.good.al
new file mode 100644
index 0000000..e3a91af
--- /dev/null
+++ b/microsoft/knowledge/ui/set-selection-filter-list-scope.good.al
@@ -0,0 +1,14 @@
+// Good: check MarkedOnly before deciding which scope to process.
+// When MarkedOnly is false (cursor-only or Ctrl+A) fall back to Copy(Rec)
+// so every record visible in the page view is included.
+trigger OnAction()
+var
+ PriceListHeader: Record "Price List Header";
+ TempErrorMessage: Record "Error Message" temporary;
+ ProcessingCodeunit: Codeunit "My Batch Processor";
+begin
+ CurrPage.SetSelectionFilter(PriceListHeader);
+ if not PriceListHeader.MarkedOnly then
+ PriceListHeader.Copy(Rec);
+ ProcessingCodeunit.RunBatch(PriceListHeader, TempErrorMessage);
+end;
diff --git a/microsoft/knowledge/ui/set-selection-filter-list-scope.md b/microsoft/knowledge/ui/set-selection-filter-list-scope.md
new file mode 100644
index 0000000..8d0fc45
--- /dev/null
+++ b/microsoft/knowledge/ui/set-selection-filter-list-scope.md
@@ -0,0 +1,28 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [set-selection-filter, marked-only, list-page, bulk-action, batch-action, selection-scope, copy-rec]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Preserve list scope after `SetSelectionFilter`
+
+## Description
+
+`CurrPage.SetSelectionFilter(Rec)` behaves differently depending on whether the user explicitly multi-selected rows. When no rows are marked โ the cursor is simply positioned on a row โ the method writes a primary key filter for that single row and leaves `MarkedOnly` as false. When the user explicitly selected multiple rows, the method marks those records and sets `MarkedOnly` to true. A batch action that calls `SetSelectionFilter` and then passes the record directly to a processing codeunit will therefore silently restrict to one row whenever the user has not made an explicit selection, which is almost never the intended behaviour for an action labelled "Verify All" or "Post All".
+
+The base platform avoids this ambiguity by routing batch list actions through Reports: the Report request page shows the derived filter and lets the user correct it before running. A direct codeunit call has no such safety net and must resolve the scope explicitly.
+
+## Best Practice
+
+After calling `SetSelectionFilter`, test `MarkedOnly`. When it is false โ meaning the user made no explicit selection, or selected all rows with Ctrl+A โ discard the single-row primary key filter by copying the page source record (`Copy(Rec)`), which carries the full page view including all active filter groups. When `MarkedOnly` is true the user made a deliberate selection and that filter should be respected as-is. Refer to [`set-selection-filter-list-scope.good.al`](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`](set-selection-filter-list-scope.bad.al).
+
+## See also
+
+`Page.SetSelectionFilter` โ https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/page/page-setselectionfilter-method
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.bad.al b/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.bad.al
new file mode 100644
index 0000000..e7e8e26
--- /dev/null
+++ b/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.bad.al
@@ -0,0 +1,59 @@
+table 50540 "Sample Shipping Agent Bad"
+{
+ fields
+ {
+ field(1; "Code"; Code[20])
+ {
+ DataClassification = CustomerContent;
+ NotBlank = true;
+ }
+ field(2; Description; Text[100])
+ {
+ DataClassification = CustomerContent;
+ }
+ }
+
+ keys
+ {
+ key(PK; "Code")
+ {
+ Clustered = true;
+ }
+ }
+
+ trigger OnInsert()
+ begin
+ TestField(Description);
+ end;
+}
+
+page 50541 "Sample Shipping Agents Bad"
+{
+ PageType = List;
+ ApplicationArea = All;
+ UsageCategory = Lists;
+ SourceTable = "Sample Shipping Agent Bad";
+ DelayedInsert = true;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(Agents)
+ {
+ field("Code"; Rec."Code")
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies the code of the shipping agent.';
+ }
+ field(Description; Rec.Description)
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies a description of the shipping agent.';
+ // Required by OnInsert, but nothing marks it. The user types
+ // the row, leaves it, and only then gets the error.
+ }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.good.al b/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.good.al
new file mode 100644
index 0000000..0c63162
--- /dev/null
+++ b/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.good.al
@@ -0,0 +1,61 @@
+table 50542 "Sample Shipping Agent"
+{
+ fields
+ {
+ field(1; "Code"; Code[20])
+ {
+ DataClassification = CustomerContent;
+ NotBlank = true;
+ }
+ field(2; Description; Text[100])
+ {
+ DataClassification = CustomerContent;
+ }
+ }
+
+ keys
+ {
+ key(PK; "Code")
+ {
+ Clustered = true;
+ }
+ }
+
+ trigger OnInsert()
+ begin
+ TestField(Description);
+ end;
+}
+
+page 50543 "Sample Shipping Agents"
+{
+ PageType = List;
+ ApplicationArea = All;
+ UsageCategory = Lists;
+ SourceTable = "Sample Shipping Agent";
+ DelayedInsert = true;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(Agents)
+ {
+ field("Code"; Rec."Code")
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies the code of the shipping agent.';
+ ShowMandatory = true;
+ }
+ field(Description; Rec.Description)
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies a description of the shipping agent.';
+ // Mirrors the TestField in OnInsert. ShowMandatory is what the
+ // client reads for the marker, so it has to be set here.
+ ShowMandatory = true;
+ }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.md b/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.md
new file mode 100644
index 0000000..eeffc07
--- /dev/null
+++ b/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.md
@@ -0,0 +1,32 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [showmandatory, notblank, mandatory-field, red-asterisk, delayedinsert, testfield, page-field]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Mark code-required page fields with ShowMandatory
+
+> Contributions welcome โ open a PR to refine or extend this article.
+
+## Description
+
+`ShowMandatory` draws the red asterisk on a page field and, per the platform documentation, enforces no validation. The reverse is not reliable: code that enforces a value โ `TestField` in `OnInsert`/`OnModify`, a `NotBlank` table field, a mandatory setup value โ does not guarantee that the page field renders as mandatory. Because the two halves are independent, it is easy to ship a field that the code requires but the UI presents as optional. Microsoft documents that `NotBlank` can mark primary-key fields, but current client behavior does not do so consistently; on non-primary-key fields, a value that was never entered is not validated at all. `ShowMandatory` also overrides any marking `NotBlank` would contribute, so set it explicitly when the page must communicate a requirement. The gap is widest on a list page with `DelayedInsert = true`, where the enforcing error surfaces only when the user leaves the row โ after the rest of the line is typed, with nothing having indicated which field was missing.
+
+## Best Practice
+
+Set `ShowMandatory = true` on every visible, editable page field whose value the user must supply before the record can be committed or an action can complete, and leave the enforcement in place: the property is presentation, `TestField`/`Error` is the guarantee, and the two belong together in the same change. When the requirement is conditional, bind `ShowMandatory` to a Boolean variable or field that mirrors the condition the enforcement checks โ the base application drives `Vendor Invoice No.` on the Purchase Invoice page from an `Ext. Doc. No. Mandatory` setup flag this way. Two expression limits are worth knowing: the property cannot call an AL method, so compute the value into a variable first, and a numeric field that has a default value counts as filled, so it never shows the asterisk. See sample: [`showmandatory-on-code-required-page-fields.good.al`](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`](showmandatory-on-code-required-page-fields.bad.al).
+
+## See also
+
+`ShowMandatory` property โ https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-showmandatory-property
+
+`NotBlank` property โ https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-notblank-property
+
+Review finding this article generalizes โ https://github.com/microsoft/BCApps/pull/9315#discussion_r3568817946
diff --git a/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/validate-request-page-input-in-onqueryclosepage.bad.al b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.bad.al
new file mode 100644
index 0000000..a74c3d0
--- /dev/null
+++ b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.bad.al
@@ -0,0 +1,96 @@
+report 50545 "Sample Statement Late Check"
+{
+ ApplicationArea = All;
+ UsageCategory = ReportsAndAnalysis;
+ Caption = 'Sample Statement Late Check';
+
+ dataset
+ {
+ dataitem(CustLedgerEntry; "Cust. Ledger Entry")
+ {
+ column(CustomerNo; "Customer No.") { }
+ column(Amount; Amount) { }
+ }
+ }
+
+ requestpage
+ {
+ layout
+ {
+ area(content)
+ {
+ group(Options)
+ {
+ field(StatementDateField; StatementDate)
+ {
+ ApplicationArea = All;
+ Caption = 'Statement Date';
+ ToolTip = 'Specifies the date the statement is printed for.';
+ ShowMandatory = true;
+ }
+ }
+ }
+ }
+ // No OnQueryClosePage: nothing inspects the input while the page is open.
+ }
+
+ var
+ StatementDate: Date;
+ StatementDateMissingErr: Label 'Enter a statement date.';
+
+ trigger OnPreReport()
+ begin
+ // The request page is already closed. The user cannot correct the date
+ // here โ the run is aborted and every entry on the page is lost.
+ if StatementDate = 0D then
+ Error(StatementDateMissingErr);
+ end;
+}
+
+report 50546 "Sample Statement Close Trap"
+{
+ ApplicationArea = All;
+ UsageCategory = ReportsAndAnalysis;
+ Caption = 'Sample Statement Close Trap';
+
+ dataset
+ {
+ dataitem(CustLedgerEntry; "Cust. Ledger Entry")
+ {
+ column(CustomerNo; "Customer No.") { }
+ column(Amount; Amount) { }
+ }
+ }
+
+ requestpage
+ {
+ layout
+ {
+ area(content)
+ {
+ group(Options)
+ {
+ field(StatementDateField; StatementDate)
+ {
+ ApplicationArea = All;
+ Caption = 'Statement Date';
+ ToolTip = 'Specifies the date the statement is printed for.';
+ ShowMandatory = true;
+ }
+ }
+ }
+ }
+
+ trigger OnQueryClosePage(CloseAction: Action): Boolean
+ begin
+ // No close-action guard. Cancel and Esc raise the error too, and an
+ // error prevents the page from closing โ the user cannot get out.
+ if StatementDate = 0D then
+ Error(StatementDateMissingErr);
+ end;
+ }
+
+ var
+ StatementDate: Date;
+ StatementDateMissingErr: Label 'Enter a statement date.';
+}
diff --git a/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.good.al b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.good.al
new file mode 100644
index 0000000..4ad160a
--- /dev/null
+++ b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.good.al
@@ -0,0 +1,62 @@
+report 50547 "Sample Statement Good"
+{
+ ApplicationArea = All;
+ UsageCategory = ReportsAndAnalysis;
+ Caption = 'Sample Statement Good';
+
+ dataset
+ {
+ dataitem(CustLedgerEntry; "Cust. Ledger Entry")
+ {
+ column(CustomerNo; "Customer No.") { }
+ column(Amount; Amount) { }
+ }
+ }
+
+ requestpage
+ {
+ layout
+ {
+ area(content)
+ {
+ group(Options)
+ {
+ field(StatementDateField; StatementDate)
+ {
+ ApplicationArea = All;
+ Caption = 'Statement Date';
+ ToolTip = 'Specifies the date the statement is printed for.';
+ ShowMandatory = true;
+ }
+ }
+ }
+ }
+
+ trigger OnQueryClosePage(CloseAction: Action): Boolean
+ begin
+ // Only when the user confirmed the run. Erroring on Cancel or Esc
+ // would trap the user in a page that refuses to close. The error
+ // itself keeps the page open, so the date can be fixed in place.
+ if CloseAction = Action::OK then
+ CheckStatementDate();
+ end;
+ }
+
+ var
+ StatementDate: Date;
+ StatementDateMissingErr: Label 'Enter a statement date.';
+
+ trigger OnPreReport()
+ begin
+ // The same check for runs that have no request page: job queue entries,
+ // Report.Run with the request window suppressed, scheduled and
+ // web-service invocations.
+ CheckStatementDate();
+ end;
+
+ local procedure CheckStatementDate()
+ begin
+ if StatementDate = 0D then
+ Error(StatementDateMissingErr);
+ end;
+}
diff --git a/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md
new file mode 100644
index 0000000..d796827
--- /dev/null
+++ b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md
@@ -0,0 +1,32 @@
+---
+bc-version: [all]
+domain: ui
+keywords: [request-page, onqueryclosepage, onprereport, closeaction, mandatory-input, report-validation, job-queue]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Validate request-page input in OnQueryClosePage, not only in OnPreReport
+
+> Contributions welcome โ open a PR to refine or extend this article.
+
+## Description
+
+`OnPreReport` runs after the request page has closed and before the data items are processed. A validation error raised there aborts the run with the request page already gone: everything the user typed is lost, and the only way forward is to open the report again and retype it. The request page's own `OnQueryClosePage` trigger runs while the page is still open, and the platform does not close a page whose `OnQueryClosePage` raises an error or returns `false` โ so the same check placed there leaves the user in front of their input, with the offending field still filled in and correctable. Moving the check rather than duplicating it fails the other way: a report can run with no request page at all โ `Report.Run`/`Report.RunModal` with the request window suppressed, `UseRequestPage = false`, job queue entries, scheduled and web-service invocations โ and `OnQueryClosePage` never fires on those paths.
+
+## Best Practice
+
+Put the validation in one local procedure and call it from both places: from the request page's `OnQueryClosePage`, so an interactive user can correct the input where they entered it, and from `OnPreReport` (or the relevant `OnPreDataItem`), so a run without a request page is still refused. Guard the interactive call on the close action โ validate only when the user confirmed the run, for example `if CloseAction = Action::OK then`. The base application uses this shape; report 292, `Copy Sales Document`, validates its request-page input in `OnQueryClosePage` behind a close-action check. Mark the control with `ShowMandatory` as well, so the requirement is visible before the user submits โ see `showmandatory-on-code-required-page-fields.md`. See sample: [`validate-request-page-input-in-onqueryclosepage.good.al`](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`](validate-request-page-input-in-onqueryclosepage.bad.al).
+
+## See also
+
+`OnQueryClosePage` (Request Page) trigger โ https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/requestpage/devenv-onqueryclosepage-requestpage-trigger
+
+`OnPreReport` (Report) trigger โ https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/report/devenv-onprereport-report-trigger
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.bad.al b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.bad.al
new file mode 100644
index 0000000..af1dabf
--- /dev/null
+++ b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.bad.al
@@ -0,0 +1,32 @@
+codeunit 50303 "Upgrade Phases Bad"
+{
+ Subtype = Upgrade;
+
+ trigger OnCheckPreconditionsPerCompany()
+ begin
+ // A precondition check must not repair the data it is checking.
+ RenamePostingGroup();
+ end;
+
+ trigger OnValidateUpgradePerCompany()
+ begin
+ // Validation must not perform a migration omitted from OnUpgrade.
+ MigrateCustomerPostingGroups();
+ end;
+
+ local procedure RenamePostingGroup()
+ var
+ CustomerPostingGroup: Record "Customer Posting Group";
+ begin
+ if CustomerPostingGroup.Get('OLD') then
+ CustomerPostingGroup.Rename('NEW');
+ end;
+
+ local procedure MigrateCustomerPostingGroups()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.SetRange("Customer Posting Group", 'OLD');
+ Customer.ModifyAll("Customer Posting Group", 'NEW');
+ end;
+}
diff --git a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.good.al b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.good.al
new file mode 100644
index 0000000..6929741
--- /dev/null
+++ b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.good.al
@@ -0,0 +1,63 @@
+codeunit 50302 "Upgrade Phases Good"
+{
+ Subtype = Upgrade;
+
+ trigger OnCheckPreconditionsPerCompany()
+ begin
+ CheckTargetPostingGroup();
+ end;
+
+ trigger OnUpgradePerCompany()
+ var
+ UpgradeTag: Codeunit "Upgrade Tag";
+ begin
+ if UpgradeTag.HasUpgradeTag(CustomerPostingGroupTag()) then
+ exit;
+
+ MigrateCustomerPostingGroups();
+ UpgradeTag.SetUpgradeTag(CustomerPostingGroupTag());
+ end;
+
+ trigger OnValidateUpgradePerCompany()
+ begin
+ CheckLegacyPostingGroupsRemoved();
+ end;
+
+ local procedure CheckTargetPostingGroup()
+ var
+ CustomerPostingGroup: Record "Customer Posting Group";
+ begin
+ if not CustomerPostingGroup.Get('NEW') then
+ Error(TargetGroupMissingErr);
+ end;
+
+ local procedure MigrateCustomerPostingGroups()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.SetRange("Customer Posting Group", 'OLD');
+ if Customer.FindSet(true) then
+ repeat
+ Customer.Validate("Customer Posting Group", 'NEW');
+ Customer.Modify(true);
+ until Customer.Next() = 0;
+ end;
+
+ local procedure CheckLegacyPostingGroupsRemoved()
+ var
+ Customer: Record Customer;
+ begin
+ Customer.SetRange("Customer Posting Group", 'OLD');
+ if not Customer.IsEmpty() then
+ Error(MigrationIncompleteErr);
+ end;
+
+ local procedure CustomerPostingGroupTag(): Code[250]
+ begin
+ exit('MS-50302-CustomerPostingGroup-20260714');
+ end;
+
+ var
+ MigrationIncompleteErr: Label 'The legacy customer posting group was not migrated.';
+ TargetGroupMissingErr: Label 'Customer posting group NEW must exist before the upgrade.';
+}
diff --git a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md
new file mode 100644
index 0000000..8770f93
--- /dev/null
+++ b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: upgrade
+keywords: [on-check-preconditions, on-validate-upgrade, on-upgrade, read-only-check, data-migration]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Upgrade check triggers do not migrate data
+
+## Description
+
+`OnCheckPreconditionsPerCompany`/`PerDatabase` run before the upgrade to verify that it can start. `OnValidateUpgradePerCompany`/`PerDatabase` run after upgrade logic to verify that it succeeded. Treat both phases as read-only checks. The `OnUpgradePerCompany`/`PerDatabase` phase is where the platform expects actual data transformation.
+
+## Best Practice
+
+Have check triggers call query-only helpers that raise an error when an invariant fails. Put every `Insert`, `Modify`, `Delete`, `Rename`, `DataTransfer`, and other migration write behind helpers called from the matching `OnUpgrade...` trigger.
+
+See sample: [`check-only-triggers-do-not-migrate-data.good.al`](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`](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 3eeaa46..830dbdb 100644
--- a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md
+++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [21..]
domain: upgrade
keywords: [datatransfer, large-dataset, bulk-update, modifyall, copyfields, new-field]
technologies: [al]
@@ -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.bad.al b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al
index 800c828..31e8371 100644
--- a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al
+++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al
@@ -7,8 +7,8 @@ codeunit 50221 "Upgrade Existing Field"
Customer: Record Customer;
DT: DataTransfer;
begin
- // "Credit Limit (LCY)" has OnValidate logic that recalculates risk fields
- // and notifies subscribers. DataTransfer skips both โ derived data drifts.
+ // DataTransfer skips the field's OnValidate logic and validation events,
+ // plus the table OnModify trigger and row-based modification events.
DT.SetTables(Database::Customer, Database::Customer);
DT.AddConstantValue(50000, Customer.FieldNo("Credit Limit (LCY)"));
DT.CopyFields();
diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al
index 0475079..7dbf24c 100644
--- a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al
+++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al
@@ -1,16 +1,17 @@
-codeunit 50220 "Upgrade New Field Init"
+codeunit 50220 "Upgrade Trigger Aware"
{
Subtype = Upgrade;
- local procedure InitializeNewFlagOnMyTable()
+ local procedure UpdateCustomerCreditLimit()
var
- MyTable: Record "My Table";
- DT: DataTransfer;
+ Customer: Record Customer;
begin
- // "New Flag" is added in the same change as this upgrade procedure.
- // No existing validation logic depends on it, so DataTransfer is safe.
- DT.SetTables(Database::"My Table", Database::"My Table");
- DT.AddConstantValue(true, MyTable.FieldNo("New Flag"));
- DT.CopyFields();
+ if Customer.FindSet(true) then
+ repeat
+ // Validate runs the field OnValidate logic; Modify(true) separately
+ // runs the table OnModify trigger and its row-based events.
+ Customer.Validate("Credit Limit (LCY)", 50000);
+ Customer.Modify(true);
+ until Customer.Next() = 0;
end;
}
diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md
index 785684f..33173da 100644
--- a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md
+++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [21..]
domain: upgrade
keywords: [datatransfer, validate-trigger, event-subscriber, side-effects, business-logic]
technologies: [al]
@@ -11,18 +11,18 @@ application-area: [all]
## Description
-`DataTransfer` writes directly at the database layer. It does not invoke field `OnValidate` triggers, table `OnModify` triggers, or any `OnAfterModifyEvent` / `OnBeforeValidate...` event subscribers that a normal `Record.Modify(true)` would. This is precisely what makes it fast โ and precisely what makes it a footgun when the field being updated has validation logic that other code relies on. The receiving code never gets the signal that a row changed, derived fields stay stale, audit hooks do not run.
+`DataTransfer` writes sets directly at the database layer, so row-based triggers and events do not run. For `CopyFields`, that includes the table `OnModify` trigger and `OnBeforeModifyEvent`/`OnAfterModifyEvent`; direct field assignment also does not call field `OnValidate` or its validation events. These are separate behaviors: `Record.Validate(Field, Value)` runs field validation, while `Record.Modify(true)` runs the table `OnModify` trigger. Calling `Modify(true)` does not retroactively validate assigned fields.
For *new fields and tables added in the same change* this is fine: nothing yet depends on the validation. For *pre-existing fields with validation logic*, `DataTransfer` quietly bypasses business logic that may be load-bearing for posting, calculation, or integration scenarios.
## Best Practice
-Use `DataTransfer` only when the field or table is new in the same change โ initial population is the canonical safe case. When updating a pre-existing field that has validation logic, either use `Modify(true)` to honour the triggers, or, if `DataTransfer` is still required for performance reasons, leave a comment that explicitly states "validation triggers and event subscribers are intentionally not raised" and verify with the field's owner that this is safe.
+Use `DataTransfer` when set-based transfer is safe and row-level business logic is intentionally unnecessary โ initial population of a new field is the canonical case. When an existing field's validation must run, loop through records and call `Validate(Field, Value)`; if the table's modify trigger must also run, follow with `Modify(true)`. If performance requires `DataTransfer`, document exactly which field-validation and row-modification triggers or subscribers are intentionally bypassed and verify that derived data remains correct.
-See sample: `datatransfer-skips-triggers-and-subscribers.good.al`.
+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` logic, without a comment and without confirming that subscribers can be skipped. The upgrade succeeds; runtime behaviour drifts silently.
+Reaching for `DataTransfer` to update an existing field with non-trivial `OnValidate` or `OnModify` logic, without confirming that both validation and row-modification subscribers can be skipped. Replacing it with only `Modify(true)` is also incomplete when field validation is required; call `Validate` for that field first.
-See sample: `datatransfer-skips-triggers-and-subscribers.bad.al`.
+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.good.al b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al
index 7a83df2..09a14f5 100644
--- a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al
+++ b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al
@@ -13,7 +13,7 @@ codeunit 50206 "Upgrade Graceful"
begin
if not Customer.Get(CustomerNo) then begin
Session.LogMessage(
- '0000ABC',
+ 'UPG0001',
'Customer not found during upgrade',
Verbosity::Warning,
DataClassification::SystemMetadata,
diff --git a/microsoft/knowledge/upgrade/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.bad.al b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al
index e3381ad..da501e6 100644
--- a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al
+++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al
@@ -4,8 +4,7 @@ codeunit 50211 "Install My Extension"
trigger OnInstallAppPerCompany()
begin
- // No DataVersion() guard โ this runs on every reinstall and upgrade
- // path, duplicating seed rows.
+ // No DataVersion() guard: a reinstall duplicates seed rows.
SeedDefaultRows();
end;
diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md
index 260b15b..5cbdec8 100644
--- a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md
+++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md
@@ -11,20 +11,21 @@ application-area: [all]
## Description
-On the first install of an extension on a tenant the platform records a zero data version: `AppInfo.DataVersion()` returns `Version.Create('0.0.0.0')`. Subsequent upgrades record the actual previous version. The `OnInstallAppPerCompany` trigger uses this distinction to detect a brand-new install โ for example, to seed default rows that should not be re-inserted on a normal upgrade. This is the one place where reading `DataVersion()` is the right tool; for everything else, use an upgrade tag.
+On the first install of an extension on a tenant the platform records a zero data version: `AppInfo.DataVersion()` returns `Version.Create('0.0.0.0')`. During reinstall, `DataVersion()` identifies the previously installed data version. The `OnInstallAppPerCompany` trigger uses this distinction to separate a brand-new install from a reinstall. Ordinary version upgrades do not run install code.
## Best Practice
-In `OnInstallAppPerCompany`, fetch the current `ModuleInfo` via `NavApp.GetCurrentModuleInfo`, compare `AppInfo.DataVersion()` to `Version.Create('0.0.0.0')`, and run install-only seed logic only when they match. On any non-zero data version, exit immediately โ that path is an upgrade, not an install.
+In `OnInstallAppPerCompany`, fetch the current `ModuleInfo` via `NavApp.GetCurrentModuleInfo`, compare `AppInfo.DataVersion()` to `Version.Create('0.0.0.0')`, and run first-install seed logic only when they match. On a non-zero data version, follow the reinstall path or exit.
-See sample: `first-install-dataversion-zero-check.good.al`.
+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, install-only seed code re-runs on every upgrade and duplicates rows.
+Treating `OnInstallAppPerCompany` as if it always implies "fresh tenant". The trigger also fires when reinstalling over an existing data set; without the `0.0.0.0` guard, first-install seed code can run again and duplicate rows.
-See sample: `first-install-dataversion-zero-check.bad.al`.
+See sample: [`first-install-dataversion-zero-check.bad.al`](first-install-dataversion-zero-check.bad.al).
## See also
- `use-upgrade-tags-not-version-checks.md` โ for upgrade steps after first install, use upgrade tags rather than `DataVersion`.
+- `install-code-does-not-run-on-version-upgrade.md` โ ordinary version upgrades invoke upgrade code, not install code.
diff --git a/microsoft/knowledge/upgrade/guard-database-reads.md b/microsoft/knowledge/upgrade/guard-database-reads.md
index c5bc206..09a99e3 100644
--- a/microsoft/knowledge/upgrade/guard-database-reads.md
+++ b/microsoft/knowledge/upgrade/guard-database-reads.md
@@ -17,10 +17,10 @@ Inside an upgrade codeunit (or any procedure transitively invoked from `OnUpgrad
Wrap every read in an `if`. `if Item.Get(No) then ...`, `if Customer.FindSet() then;`, `if not Vendor.FindLast() then exit;`. The empty-then form `if Customer.FindSet() then;` is the idiomatic way to attempt a read whose only purpose is to position a record, while swallowing the "not found" case.
-See sample: `guard-database-reads.good.al`.
+See sample: [`guard-database-reads.good.al`](guard-database-reads.good.al).
## Anti Pattern
Calling `Item.Get()`, `Customer.FindSet()`, or `Vendor.FindLast()` bare in upgrade code. The first tenant whose data does not match the upgrade's assumptions will fail to upgrade.
-See sample: `guard-database-reads.bad.al`.
+See sample: [`guard-database-reads.bad.al`](guard-database-reads.bad.al).
diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md
index 4733ef2..bfa0f03 100644
--- a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md
+++ b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md
@@ -23,10 +23,10 @@ Several legitimate cases do NOT need upgrade code:
When a new field on an existing table has an `InitValue` that matters, ship an upgrade procedure that walks the existing rows and sets the field to the same value โ typically via `DataTransfer.AddConstantValue` for performance โ guarded by an upgrade tag.
-See sample: `initvalue-does-not-update-existing-rows.good.al`.
+See sample: [`initvalue-does-not-update-existing-rows.good.al`](initvalue-does-not-update-existing-rows.good.al).
## Anti Pattern
Adding a field with `InitValue = true;` (or any non-default `InitValue`) and shipping no upgrade code. Existing rows silently carry the datatype default, leaving the table in two states: rows created before the upgrade with the wrong value, and rows created after with the right one.
-See sample: `initvalue-does-not-update-existing-rows.bad.al`.
+See sample: [`initvalue-does-not-update-existing-rows.bad.al`](initvalue-does-not-update-existing-rows.bad.al).
diff --git a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.bad.al b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.bad.al
new file mode 100644
index 0000000..bb19d21
--- /dev/null
+++ b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.bad.al
@@ -0,0 +1,20 @@
+codeunit 50306 "My App Install Only"
+{
+ Subtype = Install;
+
+ trigger OnInstallAppPerCompany()
+ begin
+ // A normal version upgrade never invokes this migration.
+ MigrateLegacySetup();
+ end;
+
+ local procedure MigrateLegacySetup()
+ var
+ MyAppSetup: Record "My App Setup";
+ begin
+ if MyAppSetup.Get() then begin
+ MyAppSetup."Configuration Version" := 2;
+ MyAppSetup.Modify(true);
+ end;
+ end;
+}
diff --git a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.good.al b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.good.al
new file mode 100644
index 0000000..c77f3cf
--- /dev/null
+++ b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.good.al
@@ -0,0 +1,48 @@
+codeunit 50304 "My App Install"
+{
+ Subtype = Install;
+
+ trigger OnInstallAppPerCompany()
+ begin
+ InitializeSetup();
+ end;
+
+ local procedure InitializeSetup()
+ var
+ MyAppSetup: Record "My App Setup";
+ begin
+ if MyAppSetup.IsEmpty() then
+ MyAppSetup.Insert(true);
+ end;
+}
+
+codeunit 50305 "My App Upgrade"
+{
+ Subtype = Upgrade;
+
+ trigger OnUpgradePerCompany()
+ var
+ UpgradeTag: Codeunit "Upgrade Tag";
+ begin
+ if UpgradeTag.HasUpgradeTag(ConfigurationVersionTag()) then
+ exit;
+
+ MigrateLegacySetup();
+ UpgradeTag.SetUpgradeTag(ConfigurationVersionTag());
+ end;
+
+ local procedure MigrateLegacySetup()
+ var
+ MyAppSetup: Record "My App Setup";
+ begin
+ if MyAppSetup.Get() then begin
+ MyAppSetup."Configuration Version" := 2;
+ MyAppSetup.Modify(true);
+ end;
+ end;
+
+ local procedure ConfigurationVersionTag(): Code[250]
+ begin
+ exit('MS-50305-ConfigurationVersion-20260714');
+ end;
+}
diff --git a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md
new file mode 100644
index 0000000..52c3989
--- /dev/null
+++ b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: upgrade
+keywords: [install-codeunit, subtype-install, on-install-app, version-upgrade, upgrade-codeunit]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Install code does not run during a version upgrade
+
+## Description
+
+An install codeunit runs when an extension is installed for the first time or an uninstalled version is installed again. Installing a higher extension version through the data-upgrade operation does not invoke `OnInstallAppPerCompany` or `OnInstallAppPerDatabase`. Ordinary version-to-version migration is dispatched only through upgrade codeunits.
+
+## Best Practice
+
+Use `Subtype = Install` for first-install and reinstall initialization. Put version migration in a separate `Subtype = Upgrade` codeunit and enter it from `OnUpgradePerCompany` or `OnUpgradePerDatabase`.
+
+See sample: [`install-code-does-not-run-on-version-upgrade.good.al`](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`](install-code-does-not-run-on-version-upgrade.bad.al).
diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al
index 69994e6..35b848b 100644
--- a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al
+++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al
@@ -4,10 +4,21 @@ codeunit 50235 "Upgrade With Validation"
trigger OnValidateUpgradePerCompany()
begin
- // No skip logic and no written justification โ full-table validation
- // runs on every single upgrade pass.
+ // A full-table scan repeats on every upgrade.
ValidateAllCustomers();
end;
- local procedure ValidateAllCustomers() begin end;
+ local procedure ValidateAllCustomers()
+ var
+ Customer: Record Customer;
+ begin
+ if Customer.FindSet() then
+ repeat
+ if Customer."Customer Posting Group" = 'OLD' then
+ Error(MigrationIncompleteErr);
+ until Customer.Next() = 0;
+ end;
+
+ var
+ MigrationIncompleteErr: Label 'The legacy customer posting group was not migrated.';
}
diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al
index 9a5a83b..8680775 100644
--- a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al
+++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al
@@ -3,23 +3,19 @@ codeunit 50234 "Upgrade With Validation"
Subtype = Upgrade;
trigger OnValidateUpgradePerCompany()
+ begin
+ CheckNoLegacyPostingGroups();
+ end;
+
+ local procedure CheckNoLegacyPostingGroups()
var
- UpgradeTag: Codeunit "Upgrade Tag";
+ Customer: Record Customer;
begin
- // Justification: regulatory compliance requires a full-table scan once
- // per tenant after this release. Tag prevents re-runs.
- if UpgradeTag.HasUpgradeTag(MyValidationUpgradeTag()) then
- exit;
-
- ValidateAllCustomers();
-
- UpgradeTag.SetUpgradeTag(MyValidationUpgradeTag());
+ Customer.SetRange("Customer Posting Group", 'OLD');
+ if not Customer.IsEmpty() then
+ Error(MigrationIncompleteErr);
end;
- local procedure ValidateAllCustomers() begin end;
-
- local procedure MyValidationUpgradeTag(): Code[250]
- begin
- exit('MS-123456-CustomerValidation-20240101');
- end;
+ var
+ MigrationIncompleteErr: Label 'The legacy customer posting group was not migrated.';
}
diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md
index 2c02def..3095655 100644
--- a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md
+++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md
@@ -1,26 +1,26 @@
---
bc-version: [all]
domain: upgrade
-keywords: [on-validate-upgrade-per-company, performance-impact, skip-logic, justification, upgrade-tag]
+keywords: [on-validate-upgrade-per-company, performance-impact, bounded-query, justification, read-only-check]
technologies: [al]
countries: [w1]
application-area: [all]
---
-# Performance-impacting upgrade triggers need justification and skip logic
+# Keep upgrade validation checks bounded
## Description
-Triggers such as `OnValidateUpgradePerCompany` run on every upgrade pass. When their body performs non-trivial work โ full-table scans, cross-table validations โ the cost is paid on every upgrade of every tenant, even when there is nothing to validate. That cost is acceptable only when the validation is critical (regulatory compliance, data-integrity guarantees the platform depends on) AND the trigger short-circuits once it has done its work.
+Triggers such as `OnValidateUpgradePerCompany` run on every upgrade pass. A full-table scan or cross-table validation therefore adds cost to every upgrade of every tenant. Validation is a read-only lifecycle check, so it cannot make itself one-time by writing an upgrade tag.
## Best Practice
-A performance-impacting upgrade trigger carries two things: a written comment that names the reason the work has to happen on every upgrade pass, and an early-exit guard backed by an upgrade tag so the work runs at most once per tenant. The `HasUpgradeTag` check at the top exits when the validation has already been recorded; the `SetUpgradeTag` call at the bottom records completion.
+Filter directly to invalid rows and use `IsEmpty` or another bounded existence check where possible. If a broad validation is unavoidable, document the invariant that requires it and keep all data changes in `OnUpgrade...`.
-See sample: `minimize-onvalidate-upgrade-triggers.good.al`.
+See sample: [`minimize-onvalidate-upgrade-triggers.good.al`](minimize-onvalidate-upgrade-triggers.good.al).
## Anti Pattern
-Doing real work in `OnValidateUpgradePerCompany` with no upgrade-tag guard. The same scan runs every upgrade, multiplying upgrade time by the number of releases the customer takes.
+Reading every record in `OnValidateUpgradePerCompany` when a filtered existence check can prove the same invariant. The scan repeats on every upgrade.
-See sample: `minimize-onvalidate-upgrade-triggers.bad.al`.
+See sample: [`minimize-onvalidate-upgrade-triggers.bad.al`](minimize-onvalidate-upgrade-triggers.bad.al).
diff --git a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md
index eb644b6..f04fb40 100644
--- a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md
+++ b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md
@@ -19,10 +19,10 @@ The rule applies inside any codeunit with `Subtype = Upgrade` and to any procedu
Defer external calls to runtime code. If a piece of upgrade work conceptually needs data from an external service, set a flag or write a queue row during upgrade and have the runtime code make the call later (for example on first user sign-in or via job queue), where retries and degraded modes are tractable.
-See sample: `no-external-calls-in-upgrade.good.al`.
+See sample: [`no-external-calls-in-upgrade.good.al`](no-external-calls-in-upgrade.good.al).
## Anti Pattern
Calling `HttpClient.Get`, `HttpClient.Post`, or DotNet interop methods from `OnUpgradePerCompany`, `OnUpgradePerDatabase`, or any procedure they invoke.
-See sample: `no-external-calls-in-upgrade.bad.al`.
+See sample: [`no-external-calls-in-upgrade.bad.al`](no-external-calls-in-upgrade.bad.al).
diff --git a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md
index cb008ac..64a54f2 100644
--- a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md
+++ b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md
@@ -17,10 +17,10 @@ application-area: [all]
Stage the deprecation across releases. Step 1: mark `Pending` with reason and tag; consumers are warned but data and code keep working. Step 2: in a later release, transition to `Removed` and (if persisted data references the element) ship an upgrade procedure that migrates that data โ gated by an upgrade tag. The standard mechanic for retiring the actual implementation body is to remove the `#if not CLEAN` block in the same release that flips the state to `Removed`.
-See sample: `obsolete-pending-to-removed-staging.good.al`.
+See sample: [`obsolete-pending-to-removed-staging.good.al`](obsolete-pending-to-removed-staging.good.al).
## Anti Pattern
Jumping straight to `ObsoleteState = Removed` without a prior `Pending` release. Consumers have no deprecation window to migrate and any data still referencing the element is stranded. Equally wrong: leaving an element `Pending` indefinitely and never staging its removal โ the deprecation never completes.
-See sample: `obsolete-pending-to-removed-staging.bad.al`.
+See sample: [`obsolete-pending-to-removed-staging.bad.al`](obsolete-pending-to-removed-staging.bad.al).
diff --git a/microsoft/knowledge/upgrade/obsoletereason-need-not-restate-removal-version.md b/microsoft/knowledge/upgrade/obsoletereason-need-not-restate-removal-version.md
new file mode 100644
index 0000000..9165921
--- /dev/null
+++ b/microsoft/knowledge/upgrade/obsoletereason-need-not-restate-removal-version.md
@@ -0,0 +1,26 @@
+---
+bc-version: [all]
+domain: upgrade
+keywords: [obsolete-reason, obsolete-tag, deprecation, version, metadata, false-positive]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# ObsoleteReason need not restate the removal version; ObsoleteTag carries it
+
+## Description
+
+An obsoleted object, field, key, enum, or enum value carries both `ObsoleteReason` and `ObsoleteTag`, and the two properties have different jobs. `ObsoleteReason` is free text that explains why the element is obsolete and what replaces it. `ObsoleteTag` identifies when it became obsolete โ typically the version, release, or work item that introduced the obsoletion. The version traceability lives in `ObsoleteTag`; there is no requirement that `ObsoleteReason` also name the removal version or repeat what the tag already records. A reason that omits a version number is complete as long as it explains the deprecation and points to a replacement, provided `ObsoleteTag` pins the version.
+
+## Best Practice
+
+When `ObsoleteTag` already carries the version or tracking reference, do not flag `ObsoleteReason` for not mentioning a version or removal release. Judge `ObsoleteReason` on whether it explains the deprecation and names a replacement, and judge version traceability on `ObsoleteTag` instead.
+
+## Anti Pattern
+
+Flagging an `ObsoleteReason` as vague, incomplete, or missing a version reference solely because it does not restate the removal version, when `ObsoleteTag` already records that version. Requiring the reason to duplicate the tag's version is not a real convention.
+
+## See also
+
+- `obsoletion-requires-reason-and-tag.md` โ both properties are required; the reason names the replacement and the tag identifies when the element became obsolete.
diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al
index 22290a4..578db0b 100644
--- a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al
+++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al
@@ -1,7 +1,7 @@
codeunit 50228 "Old Method Holder"
{
- // ObsoleteState set without ObsoleteReason or ObsoleteTag.
- [Obsolete('')]
+ // Methods use the attribute, but empty reason and tag give no migration path.
+ [Obsolete('', '')]
procedure OldMethod()
begin
end;
diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al
index 8562b0c..0a0602a 100644
--- a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al
+++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al
@@ -3,7 +3,7 @@ codeunit 50227 "Old Method Holder"
[Obsolete('Use NewMethod instead for better performance', '22.0')]
procedure OldMethod()
begin
- // Body kept while ObsoleteState = Pending; warns at call sites.
+ // The method remains callable during its deprecation window.
end;
procedure NewMethod()
diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md
index 0f2e11e..b468437 100644
--- a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md
+++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md
@@ -7,29 +7,28 @@ countries: [w1]
application-area: [all]
---
-# Mark obsolete elements with `ObsoleteState`, `ObsoleteReason`, and `ObsoleteTag`
+# Give every obsolete element a reason and tag
## Description
-When a procedure, field, table, page, or enum value is being retired, AL requires three pieces of metadata to declare the deprecation:
+AL has two obsoletion mechanisms, depending on the symbol:
-- `ObsoleteState` โ `Pending` while the element still exists but is being phased out, `Removed` once it should no longer be used.
-- `ObsoleteReason` โ a short human-readable string explaining what to use instead. Tooling and downstream consumers surface this when warning callers.
-- `ObsoleteTag` โ a stable version-like marker (typically the release version in which the deprecation was introduced, e.g. `'22.0'`).
+- Objects, fields, enum types, and enum values use the `ObsoleteState`, `ObsoleteReason`, and `ObsoleteTag` properties. `Pending` warns while the element remains available; `Removed` blocks references.
+- Methods, variables, events, and other symbols use `[Obsolete('reason', 'tag')]`. They do not have an `ObsoleteState` property.
-Omitting `ObsoleteReason` or `ObsoleteTag` leaves consumers with `ObsoleteState = Pending` but no guidance and no traceability. Declaring `ObsoleteState = Removed` without a reason or tag is the same failure with a stronger blast radius.
+In both forms, the reason should name the replacement and the tag should identify when the element became obsolete. Empty or missing guidance leaves consumers without an actionable migration path.
## Best Practice
-Every obsoleted element carries all three properties together. The reason names the replacement explicitly; the tag is the version in which the deprecation was introduced and stays stable for the life of the deprecation.
+For an object or field, set all three properties together. For a method, variable, or event, provide both `[Obsolete]` arguments. Keep the original tag stable through the lifecycle rather than changing it to a planned removal version.
-See sample: `obsoletion-requires-reason-and-tag.good.al`.
+See sample: [`obsoletion-requires-reason-and-tag.good.al`](obsoletion-requires-reason-and-tag.good.al).
## Anti Pattern
-Setting only `ObsoleteState = Pending;` (or `Removed`) without `ObsoleteReason` and `ObsoleteTag`. Callers see a warning with no explanation, and the deprecation cannot be tracked by version.
+Setting only `ObsoleteState = Pending`/`Removed` on an object or field, or using `[Obsolete('', '')]` on a method, variable, or event. Both forms produce deprecation metadata without useful replacement guidance or traceability.
-See sample: `obsoletion-requires-reason-and-tag.bad.al`.
+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.bad.al b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al
index adf5cf5..3a50448 100644
--- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al
+++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al
@@ -16,5 +16,6 @@ codeunit 50213 "Upgrade Tag Registration"
exit('MS-123456-MyFeature-20240101');
end;
- // No OnGetPerCompanyUpgradeTags subscriber โ the tag is unknown to the platform.
+ // No OnGetPerCompanyUpgradeTags subscriber: SetAllUpgradeTags cannot seed this
+ // historical step for a newly initialized company, so it can run unnecessarily.
}
diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al
index 02362c9..a214717 100644
--- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al
+++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al
@@ -1,18 +1,6 @@
-codeunit 50212 "Upgrade Tag Registration"
+codeunit 50212 "Upgrade Tag Definitions"
{
- Subtype = Upgrade;
-
- trigger OnUpgradePerCompany()
- var
- UpgradeTag: Codeunit "Upgrade Tag";
- begin
- if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then
- exit;
- // Upgrade work ...
- UpgradeTag.SetUpgradeTag(MyUpgradeTag());
- end;
-
- local procedure MyUpgradeTag(): Code[250]
+ procedure MyUpgradeTag(): Code[250]
begin
exit('MS-123456-MyFeature-20240101');
end;
@@ -23,3 +11,34 @@ codeunit 50212 "Upgrade Tag Registration"
PerCompanyUpgradeTags.Add(MyUpgradeTag());
end;
}
+
+codeunit 50214 "Upgrade Tagged Feature"
+{
+ Subtype = Upgrade;
+
+ trigger OnUpgradePerCompany()
+ var
+ UpgradeTag: Codeunit "Upgrade Tag";
+ Tags: Codeunit "Upgrade Tag Definitions";
+ begin
+ if UpgradeTag.HasUpgradeTag(Tags.MyUpgradeTag()) then
+ exit;
+ // Upgrade work ...
+ UpgradeTag.SetUpgradeTag(Tags.MyUpgradeTag());
+ end;
+}
+
+codeunit 50215 "Install Tagged Feature"
+{
+ Subtype = Install;
+
+ trigger OnInstallAppPerCompany()
+ var
+ UpgradeTag: Codeunit "Upgrade Tag";
+ Tags: Codeunit "Upgrade Tag Definitions";
+ begin
+ // Existing-company install path; new-company initialization uses
+ // SetAllUpgradeTags and the subscriber above.
+ UpgradeTag.SetUpgradeTag(Tags.MyUpgradeTag());
+ end;
+}
diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md
index a413520..0bba7c2 100644
--- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md
+++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md
@@ -7,22 +7,22 @@ countries: [w1]
application-area: [all]
---
-# Register every upgrade tag with the platform via an event subscriber
+# Register upgrade tags that must be seeded for new companies
## Description
-The `Upgrade Tag` codeunit only recognizes a tag if the tag was published to the platform through one of two events on that codeunit: `OnGetPerCompanyUpgradeTags` for tags set inside `OnUpgradePerCompany`, and `OnGetPerDatabaseUpgradeTags` for tags set inside `OnUpgradePerDatabase`. A tag that is `Set` and `Has`-checked in code but never added to one of these lists is unknown to the platform โ its semantics around skip-on-reinstall, telemetry, and operator queries do not apply.
+`SetUpgradeTag(Tag)` directly records a completed per-company upgrade step; `HasUpgradeTag(Tag)` can then guard that step on later upgrades. The `OnGetPerCompanyUpgradeTags` subscriber serves a different path: it contributes tags to the list used by `SetAllUpgradeTags()` when a new company is initialized, marking historical upgrade steps complete so they do not run against a company that starts on the current schema.
-The registration scope must match where the tag is set: a tag used from `OnUpgradePerCompany` registers in `OnGetPerCompanyUpgradeTags`; a tag used from `OnUpgradePerDatabase` registers in `OnGetPerDatabaseUpgradeTags`. Crossing the scopes silently breaks the tag.
+Registration is not install-time seeding. When an extension is installed into an existing company and a tag must start as complete, the install code must call `SetUpgradeTag` explicitly. For new-company initialization, codeunit `Company Initialize` calls `SetAllUpgradeTags`, which obtains subscriber-provided per-company tags and inserts missing ones. Database-scoped upgrade steps use `HasDatabaseUpgradeTag`/`SetDatabaseUpgradeTag` and the corresponding per-database list.
## Best Practice
-For every new upgrade tag, add one line to the matching subscriber: `PerCompanyUpgradeTags.Add(MyUpgradeTag());` or `PerDatabaseUpgradeTags.Add(MyUpgradeTag());`. Place the subscribers in the same codeunit (or a dedicated "Upgrade Tag Definitions" codeunit) so the tag string and its registration stay together.
+In the upgrade codeunit, guard work with `HasUpgradeTag` and call `SetUpgradeTag` only after successful completion. Seed the same tag explicitly from `OnInstallAppPerCompany` when first-install logic should not run as a later upgrade. Also add historical per-company tags to `OnGetPerCompanyUpgradeTags` so `SetAllUpgradeTags` marks them complete for newly created companies. Keep the tag definition shared so all paths use the exact same value.
-See sample: `register-upgrade-tags-with-subscribers.good.al`.
+See sample: [`register-upgrade-tags-with-subscribers.good.al`](register-upgrade-tags-with-subscribers.good.al).
## Anti Pattern
-Calling `UpgradeTag.SetUpgradeTag(MyUpgradeTag())` without ever adding `MyUpgradeTag()` to the corresponding `OnGetPerCompany...` / `OnGetPerDatabase...` subscriber.
+Assuming an `OnGetPerCompanyUpgradeTags` subscriber sets tags during extension installation, or omitting the subscriber and allowing old upgrade steps to run when `SetAllUpgradeTags` initializes a new company. The subscriber supplies a list; only `SetAllUpgradeTags` or an explicit `SetUpgradeTag` call persists it.
-See sample: `register-upgrade-tags-with-subscribers.bad.al`.
+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/unreleased-schema-change-needs-no-upgrade-path.md b/microsoft/knowledge/upgrade/unreleased-schema-change-needs-no-upgrade-path.md
new file mode 100644
index 0000000..248943f
--- /dev/null
+++ b/microsoft/knowledge/upgrade/unreleased-schema-change-needs-no-upgrade-path.md
@@ -0,0 +1,24 @@
+---
+bc-version: [all]
+domain: upgrade
+keywords: [released-baseline, unreleased, schema, migration, obsolete, data-loss, false-positive]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Unreleased schema changes need no upgrade or migration path
+
+## Description
+
+Upgrade and migration findings protect data and schema that have already shipped to customers. A schema element โ a table, field, key, or enum โ that is new in this app, or was added and then changed within the same still-unreleased development cycle, needs no upgrade code or migration path: no customer has data in it yet, so there is nothing to preserve or migrate. Such a change is not an obsoletion, data-loss, or breaking-migration defect.
+
+Release status is established from the diff, the app's `app.json` version, or a released baseline. A schema element with no released baseline has no persisted customer data to protect.
+
+## Best Practice
+
+Before asserting an obsoletion, data-loss, or breaking-migration defect, establish that the affected table, field, key, or enum existed in a released version. Do not require upgrade or migration code for schema that never shipped. When release status cannot be established from the diff, `app.json`, or a released baseline, omit the finding rather than demand a migration path.
+
+## Anti Pattern
+
+Demanding an upgrade codeunit, migration path, or data-preservation step, or flagging data loss, for a table, field, key, or enum that is new in the current unreleased cycle and has no released baseline.
diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al
index 024443c..27c972c 100644
--- a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al
+++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al
@@ -1,7 +1,7 @@
codeunit 50201 "Upgrade My Feature"
{
- // Missing Subtype = Upgrade; the OnUpgrade trigger is never dispatched.
- trigger OnUpgradePerCompany()
+ // This compiles, but no Subtype = Upgrade trigger wires it to the pipeline.
+ procedure RunUpgrade()
begin
UpgradeMyFeature();
end;
diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md
index 2dba21a..8bf558d 100644
--- a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md
+++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md
@@ -11,16 +11,16 @@ application-area: [all]
## Description
-A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upgrade`. The platform then dispatches the `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on that codeunit during upgrade. A codeunit without `Subtype = Upgrade` โ even one that declares an `OnUpgradePerCompany` trigger โ is not an upgrade codeunit, and reviewers ignore it for upgrade concerns. Conversely, any procedure invoked transitively from an `OnUpgrade...` trigger of an upgrade codeunit IS upgrade code regardless of where it lives, and the upgrade rules apply to it.
+A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upgrade`. The platform then permits and dispatches the `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on that codeunit during upgrade. A normal codeunit can contain an upgrade-like `RunUpgrade` procedure, but the platform does not discover or invoke it automatically. Conversely, any procedure invoked transitively from an `OnUpgrade...` trigger of an upgrade codeunit is upgrade code regardless of where the helper lives, and the upgrade rules apply to it.
## Best Practice
Place every piece of upgrade logic in a codeunit declared with `Subtype = Upgrade;` and expose entry points via the two triggers `OnUpgradePerCompany` and `OnUpgradePerDatabase`. Helper procedures may live in normal codeunits, but they inherit the upgrade-context rules (guarded reads, no external calls, upgrade tags, etc.) when called from an upgrade trigger.
-See sample: `upgrade-codeunit-subtype.good.al`.
+See sample: [`upgrade-codeunit-subtype.good.al`](upgrade-codeunit-subtype.good.al).
## Anti Pattern
Putting upgrade-style logic in a regular codeunit that the platform never invokes during upgrade โ for example a normal codeunit with a manually invented "RunUpgrade" procedure that nothing wires to the upgrade pipeline. The migration code will simply not run.
-See sample: `upgrade-codeunit-subtype.bad.al`.
+See sample: [`upgrade-codeunit-subtype.bad.al`](upgrade-codeunit-subtype.bad.al).
diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md
index 62347d1..bb7649f 100644
--- a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md
+++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md
@@ -17,13 +17,13 @@ Each piece of upgrade logic must run exactly once per company (or database) acro
Every upgrade procedure starts with a `HasUpgradeTag` guard and ends with `SetUpgradeTag` once the work is committed. Each feature gets its own tag string so features can be re-run independently if needed.
-See sample: `use-upgrade-tags-not-version-checks.good.al`.
+See sample: [`use-upgrade-tags-not-version-checks.good.al`](use-upgrade-tags-not-version-checks.good.al).
## Anti Pattern
Branching on `MyApp.DataVersion().Major > N`, or chains of `< N` / `< M` to decide which upgrade step to run. Such code becomes unmaintainable after a few releases and silently does the wrong thing on tenants that skip versions.
-See sample: `use-upgrade-tags-not-version-checks.bad.al`.
+See sample: [`use-upgrade-tags-not-version-checks.bad.al`](use-upgrade-tags-not-version-checks.bad.al).
## See also
diff --git a/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.bad.al b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.bad.al
new file mode 100644
index 0000000..9b5ad06
--- /dev/null
+++ b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.bad.al
@@ -0,0 +1,52 @@
+// Ordinal 2 was renamed from CreditNote to CreditMemo with ordinal and caption kept. The compiler stays silent
+// and AS0082 fires only against a baseline; every schema 2.0 consumer that filters on or posts CreditNote fails.
+enum 50120 "Document Kind Bad"
+{
+ Extensible = true;
+
+ value(0; Invoice) { Caption = 'Invoice'; }
+ value(1; Order) { Caption = 'Order'; }
+ value(2; CreditMemo) { Caption = 'Credit Memo'; }
+}
+
+table 50121 "Document Header Bad"
+{
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20]) { DataClassification = CustomerContent; }
+ field(2; Kind; Enum "Document Kind Bad") { DataClassification = CustomerContent; }
+ }
+
+ keys
+ {
+ key(PK; "No.") { Clustered = true; }
+ }
+}
+
+page 50122 "Document API Bad"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'documents';
+ APIVersion = 'v1.0';
+ EntityName = 'document';
+ EntitySetName = 'documents';
+ ODataKeyFields = SystemId;
+ SourceTable = "Document Header Bad";
+ DelayedInsert = true;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ field(id; Rec.SystemId) { Caption = 'id'; Editable = false; }
+ field(number; Rec."No.") { Caption = 'number'; }
+ field(kind; Rec.Kind) { Caption = 'kind'; }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.good.al b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.good.al
new file mode 100644
index 0000000..ef3337b
--- /dev/null
+++ b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.good.al
@@ -0,0 +1,54 @@
+// Neither the name CreditNote nor its caption changes in place: schema 2.0 consumers bind to the name,
+// schema 1.0 consumers to the caption. A new kind is appended; a retired kind is obsoleted, never deleted.
+enum 50120 "Document Kind Good"
+{
+ Extensible = true;
+
+ value(0; Invoice) { Caption = 'Invoice'; }
+ value(1; Order) { Caption = 'Order'; }
+ value(2; CreditNote) { Caption = 'Credit Note'; }
+ value(3; ReturnOrder) { Caption = 'Return Order'; }
+ value(4; Quote) { Caption = 'Quote'; ObsoleteState = Pending; ObsoleteReason = 'Quotes moved to the quotes API.'; ObsoleteTag = '3.0'; }
+}
+
+table 50121 "Document Header Good"
+{
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20]) { DataClassification = CustomerContent; }
+ field(2; Kind; Enum "Document Kind Good") { DataClassification = CustomerContent; }
+ }
+
+ keys
+ {
+ key(PK; "No.") { Clustered = true; }
+ }
+}
+
+page 50122 "Document API Good"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'documents';
+ APIVersion = 'v1.0';
+ EntityName = 'document';
+ EntitySetName = 'documents';
+ ODataKeyFields = SystemId;
+ SourceTable = "Document Header Good";
+ DelayedInsert = true;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ field(id; Rec.SystemId) { Caption = 'id'; Editable = false; }
+ field(number; Rec."No.") { Caption = 'number'; }
+ field(kind; Rec.Kind) { Caption = 'kind'; }
+ }
+ }
+ }
+}
diff --git a/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
new file mode 100644
index 0000000..66fe36e
--- /dev/null
+++ b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md
@@ -0,0 +1,44 @@
+---
+bc-version: [17..]
+domain: web-services
+keywords: [api-page, enum, enum-value-name, rename, ordinal, caption, schemaversion, breaking-change, dataverse, false-positive]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Under OData schema version 2.0 an API enum field is a contract by member name; under 1.0 it is the caption
+
+> Contributions welcome โ open a PR to refine or extend this article.
+
+## Description
+
+What an API page publishes for an enum field depends on the OData `$schemaversion` the caller receives, and never on the ordinal. Under schema 2.0 the field is a strongly typed enum: `$metadata`, every response and every `$filter` carry the AL member **names**, and captions are published separately through `entityDefinitions`. Under schema 1.0 the same field is `Edm.String` and responses carry the en-US **caption**. Microsoft's API v2.0 is always schema 2.0. Custom APIs defaulted to schema 1.0 through BC 23; BC 24 changed the default to 2.0, and a caller can still pin `?$schemaversion=1.0`. Dataverse virtual tables build on API v2.0 and match choices by the value's External Name, with the integer values documented as not stable.
+
+LLMs treat one carrier as universal. Some assume the caption is serialised and report every caption change as an API break; others assume the name is serialised and wave a rename through when its ordinal and caption are kept. Each is right for one schema version and wrong for the other, and neither knows that the schema version decides. Page-shape changes are covered by `version-apis-by-adding-not-mutating-published-versions.md`; ordinal stability for persisted rows by `enum-values-additive-at-end.md`. This article is about the values inside one exposed field.
+
+## Best Practice
+
+Establish which schema versions the field is served under before changing anything about its enum. Under schema 2.0 (Microsoft's API v2.0, an explicit `$schemaversion=2.0` in the consumer contract, or another reliable context signal) the member name is the contract: keep names stable, put wording changes in `Caption`, add a value by appending a new name with an ordinal above every existing one, and retire a value through `ObsoleteState` rather than by deleting it. For a custom API that clients may still call as schema 1.0, any install of BC 17 to 23 or a caller that pins 1.0, the caption is a contract as well: change neither name nor caption in place, or publish the change as a new `APIVersion` on a new page object. A rename is out in every case: AppSourceCop AS0082 rejects it against a baseline, and dependent extensions bind to the name.
+
+See sample: [`api-enum-values-are-a-contract-by-name-not-ordinal.good.al`](api-enum-values-are-a-contract-by-name-not-ordinal.good.al).
+
+## Anti Pattern
+
+Renaming a value on an enum that an API page field exposes while keeping its ordinal and caption, or re-pointing an API page field at a source field whose enum carries different member names. Under schema 2.0 every consumer that filters on, posts, or maps the old name fails at runtime and Dataverse choices built on the old External Name stop matching; AS0082 reports the rename only when AppSourceCop runs against a baseline package, and nothing reports the re-pointed field.
+
+Detection signal: a diff hunk that changes the name in a `value(...)` line while keeping its ordinal, on an enum used by a table field that a `PageType = API` page exposes; or an API page `field(...)` whose source expression moves to a field of another enum type.
+
+The mirror image is a review defect: suppressing a caption-change finding because "the API serialises names". That holds only under schema 2.0. Do not flag a `Caption` change when the reviewer can establish schema 2.0 for every consumer; on a custom API where clients may select schema 1.0, report a caption change on an exposed value as a consumer-visible change and ask for versioning. A value appended at the end changes no contract under either schema and is never a finding.
+
+See sample: [`api-enum-values-are-a-contract-by-name-not-ordinal.bad.al`](api-enum-values-are-a-contract-by-name-not-ordinal.bad.al).
+
+## See also
+
+Deprecated features in the platform, Schema version for custom APIs (changed default in BC 24) โ https://learn.microsoft.com/dynamics365/business-central/dev-itpro/upgrade/deprecated-features-platform#changes-in-2024-release-wave-1-version-240
+
+Transitioning from API v1.0 to API v2.0, Enums and Schema version โ https://learn.microsoft.com/dynamics365/business-central/dev-itpro/api-reference/v2.0/transition-to-api-v2.0#enums
+
+Working with Virtual Tables, Table fields โ https://learn.microsoft.com/dynamics365/business-central/dev-itpro/powerplatform/powerplat-entity-modeling#table-fields
+
+AppSourceCop AS0082 (rename) โ https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/analyzers/appsourcecop-as0082 and AS0083 (delete) โ https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/analyzers/appsourcecop-as0083
diff --git a/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md b/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md
index 2358a6b..8f6a73c 100644
--- a/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md
+++ b/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md
@@ -17,10 +17,10 @@ An API meant purely for reading โ a reporting or lookup endpoint โ is not re
For a read-only / reporting API page set all three CRUD guards off โ `InsertAllowed = false`, `ModifyAllowed = false`, `DeleteAllowed = false` โ and mark the page `Editable = false`. The endpoint then serves GET requests and rejects any insert, modify, or delete, matching the read-only contract regardless of the caller. Make the read-only stance explicit rather than depending on the writable default.
-See sample: `disable-write-operations-on-read-only-api-pages.good.al`.
+See sample: [`disable-write-operations-on-read-only-api-pages.good.al`](disable-write-operations-on-read-only-api-pages.good.al).
## Anti Pattern
An API intended for read-only consumption that omits the CRUD guards, leaving `InsertAllowed`, `ModifyAllowed`, and `DeleteAllowed` at their writable defaults. The endpoint silently accepts POST, PATCH, and DELETE, so a client can mutate or remove data the API was never meant to expose for writing. The detection signal: a read-only/reporting `PageType = API` page that does not set the three `*Allowed = false` properties.
-See sample: `disable-write-operations-on-read-only-api-pages.bad.al`.
+See sample: [`disable-write-operations-on-read-only-api-pages.bad.al`](disable-write-operations-on-read-only-api-pages.bad.al).
diff --git a/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md b/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md
index 739b5aa..4337b41 100644
--- a/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md
+++ b/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md
@@ -17,10 +17,10 @@ This is about the data-consistency contract of an API endpoint: what a consumer
For an API page that must expose only committed data, set the endpoint's read isolation once as the page opens: in the `OnOpenPage` trigger write `Rec.ReadIsolation := IsolationLevel::ReadCommitted;`. Every read the endpoint then serves ignores uncommitted writes from concurrent transactions, so a consumer never receives a row that another transaction might still roll back.
-See sample: `expose-only-committed-data-from-api-reads.good.al`.
+See sample: [`expose-only-committed-data-from-api-reads.good.al`](expose-only-committed-data-from-api-reads.good.al).
## Anti Pattern
An API intended to return committed-only data that sets no isolation level, leaving reads at the default that can observe in-flight, uncommitted writes. A consumer can fetch a row created by a concurrent transaction that is later rolled back โ a dirty read that surfaces data which never durably existed. The detection signal: a committed-only read API with no `Rec.ReadIsolation := IsolationLevel::ReadCommitted` in `OnOpenPage`.
-See sample: `expose-only-committed-data-from-api-reads.bad.al`.
+See sample: [`expose-only-committed-data-from-api-reads.bad.al`](expose-only-committed-data-from-api-reads.bad.al).
diff --git a/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md b/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md
index 7f0ed87..18908a7 100644
--- a/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md
+++ b/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md
@@ -17,10 +17,10 @@ An API consumer that needs to *do* something to a record โ post it, ship it, r
Declare the operation as `[ServiceEnabled] procedure Post(var ActionContext: WebServiceActionContext)` on the API page. Inside, perform the operation against `Rec`, then call a `SetActionResponse` helper that writes the result โ the bound record and its id โ back into the `WebServiceActionContext` so the caller receives a well-formed response. The operation is now an explicit, named endpoint action separate from ordinary field writes.
-See sample: `expose-operations-as-bound-actions.good.al`.
+See sample: [`expose-operations-as-bound-actions.good.al`](expose-operations-as-bound-actions.good.al).
## Anti Pattern
Exposing a writable Boolean (for example `posted`) whose `OnValidate` performs the posting. A client that PATCHes the field to `true` โ an action indistinguishable from any other data edit โ silently triggers a side-effecting business operation. The detection signal: an API page field whose `OnValidate` posts, ships, or releases, instead of a `[ServiceEnabled]` bound action.
-See sample: `expose-operations-as-bound-actions.bad.al`.
+See sample: [`expose-operations-as-bound-actions.bad.al`](expose-operations-as-bound-actions.bad.al).
diff --git a/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md b/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md
index 93d2dcd..f34e9a4 100644
--- a/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md
+++ b/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md
@@ -17,10 +17,10 @@ Every BC table carries a `SystemId` โ an immutable GUID assigned at insert and
Set `ODataKeyFields = SystemId` so OData routes records by the stable GUID, and expose it as `field(id; Rec.SystemId)` marked `Editable = false`. Clients then address a record at `.../customers()`, an identity that survives any rename of the business key. Keep the business key (for example `No.`) as an ordinary exposed field, not as the OData key.
-See sample: `expose-systemid-as-the-api-key.good.al`.
+See sample: [`expose-systemid-as-the-api-key.good.al`](expose-systemid-as-the-api-key.good.al).
## Anti Pattern
Setting `ODataKeyFields = "No."` so the endpoint addresses records by a renamable business field. As soon as a user changes that `No.`, every external reference built on the old value points at nothing, silently breaking integrations. The detection signal: `ODataKeyFields` set to a business field rather than `SystemId`, or an API page that exposes no `id` field bound to `Rec.SystemId`.
-See sample: `expose-systemid-as-the-api-key.bad.al`.
+See sample: [`expose-systemid-as-the-api-key.bad.al`](expose-systemid-as-the-api-key.bad.al).
diff --git a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.bad.al b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.bad.al
new file mode 100644
index 0000000..a629672
--- /dev/null
+++ b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.bad.al
@@ -0,0 +1,80 @@
+page 50353 "WS Order API Bad"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'order';
+ EntitySetName = 'orders';
+ ODataKeyFields = SystemId;
+ SourceTable = "Sales Header";
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ part(lines; "WS Order Line API Bad")
+ {
+ EntityName = 'orderLine';
+ EntitySetName = 'orderLines';
+ Multiplicity = ZeroOrOne;
+ SubPageLink = "Order No." = Field("No.");
+ }
+ }
+ }
+ }
+}
+
+table 50353 "WS Order Line Bad"
+{
+ fields
+ {
+ field(1; "Entry No."; Integer)
+ {
+ AutoIncrement = true;
+ }
+ field(2; "Order No."; Code[20])
+ {
+ TableRelation = "Sales Header"."No.";
+ }
+ }
+
+ keys
+ {
+ key(PK; "Entry No.")
+ {
+ Clustered = true;
+ }
+ }
+}
+
+page 50354 "WS Order Line API Bad"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'orderLine';
+ EntitySetName = 'orderLines';
+ ODataKeyFields = SystemId;
+ SourceTable = "WS Order Line Bad";
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ field(id; Rec.SystemId)
+ {
+ Editable = false;
+ }
+ field(orderNumber; Rec."Order No.")
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.good.al b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.good.al
new file mode 100644
index 0000000..4b7482a
--- /dev/null
+++ b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.good.al
@@ -0,0 +1,151 @@
+page 50350 "WS Order API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'order';
+ EntitySetName = 'orders';
+ ODataKeyFields = SystemId;
+ SourceTable = "Sales Header";
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ field(id; Rec.SystemId)
+ {
+ Editable = false;
+ }
+ part(lines; "WS Order Line API")
+ {
+ EntityName = 'orderLine';
+ EntitySetName = 'orderLines';
+ SubPageLink = "Order Id" = Field(SystemId);
+ }
+ part(summary; "WS Order Summary API")
+ {
+ EntityName = 'orderSummary';
+ Multiplicity = ZeroOrOne;
+ SubPageLink = "Order Id" = Field(SystemId);
+ }
+ }
+ }
+ }
+}
+
+table 50350 "WS Order Line"
+{
+ fields
+ {
+ field(1; "Entry No."; Integer)
+ {
+ AutoIncrement = true;
+ }
+ field(2; "Order Id"; Guid)
+ {
+ TableRelation = "Sales Header".SystemId;
+ }
+ field(3; Description; Text[100])
+ {
+ }
+ }
+
+ keys
+ {
+ key(PK; "Entry No.")
+ {
+ Clustered = true;
+ }
+ }
+}
+
+table 50351 "WS Order Summary"
+{
+ fields
+ {
+ field(1; "Order Id"; Guid)
+ {
+ TableRelation = "Sales Header".SystemId;
+ }
+ field(2; Summary; Text[100])
+ {
+ }
+ }
+
+ keys
+ {
+ key(PK; "Order Id")
+ {
+ Clustered = true;
+ }
+ }
+}
+
+page 50351 "WS Order Line API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'orderLine';
+ EntitySetName = 'orderLines';
+ ODataKeyFields = SystemId;
+ SourceTable = "WS Order Line";
+ DelayedInsert = true;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ field(id; Rec.SystemId)
+ {
+ Editable = false;
+ }
+ field(orderId; Rec."Order Id")
+ {
+ }
+ field(description; Rec.Description)
+ {
+ }
+ }
+ }
+ }
+}
+
+page 50352 "WS Order Summary API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'orderSummary';
+ EntitySetName = 'orderSummaries';
+ ODataKeyFields = SystemId;
+ SourceTable = "WS Order Summary";
+ DelayedInsert = true;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ field(id; Rec.SystemId)
+ {
+ Editable = false;
+ }
+ field(orderId; Rec."Order Id")
+ {
+ }
+ field(summary; Rec.Summary)
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md
new file mode 100644
index 0000000..2f92c0c
--- /dev/null
+++ b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md
@@ -0,0 +1,30 @@
+---
+bc-version: [17..]
+domain: web-services
+keywords: [api-page, page-part, subpagelink, systemid, multiplicity, deep-insert, navigation-property]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# Link API parts on SystemId and choose the correct multiplicity
+
+## Description
+
+`Multiplicity` is available from runtime 6.3 (Business Central 17.3) and defaults an API page part to a 1:N collection. The multiplicity-specific guidance therefore does not apply to BC 17.0 through 17.2. An API page part creates an OData navigation property and, for collection multiplicity, enables deep insert of child entities. When a custom parent API is keyed by its immutable `SystemId`, its child should carry a related GUID foreign key so the navigation constraint uses that same stable external identity. `Multiplicity` controls whether metadata exposes an object (`ZeroOrOne`) or a collection (`Many`).
+
+## Best Practice
+
+Define the child foreign key as `Guid` with a `TableRelation` to the parent table's `SystemId`, then use `SubPageLink = "" = Field(SystemId)` on the parent API page. A child collection may omit `Multiplicity` and rely on the default 1:N relationship, or declare `Multiplicity = Many` explicitly. Set `Multiplicity = ZeroOrOne` when the intended navigation metadata is a singleton.
+
+See sample: [`link-api-parts-on-systemid-and-set-multiplicity.good.al`](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`](link-api-parts-on-systemid-and-set-multiplicity.bad.al).
+
+## Source
+
+[Developing a custom API](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-develop-custom-api) and [Multiplicity property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-multiplicity-property).
diff --git a/microsoft/knowledge/web-services/set-required-api-page-properties.bad.al b/microsoft/knowledge/web-services/set-required-api-page-properties.bad.al
index 927bc2d..c248cb2 100644
--- a/microsoft/knowledge/web-services/set-required-api-page-properties.bad.al
+++ b/microsoft/knowledge/web-services/set-required-api-page-properties.bad.al
@@ -1,12 +1,13 @@
-// Malformed API endpoint: APIPublisher and APIGroup are missing, and there is
-// no SourceTable. The page compiles but the route cannot be composed, so the
-// entity is never published where an integration expects it.
+// APIVersion is omitted. This is valid, but the endpoint defaults to beta
+// instead of publishing the intended explicit stable contract.
page 50341 "WS Required Props Bad"
{
PageType = API;
- APIVersion = 'v1.0';
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
EntityName = 'customer';
EntitySetName = 'customers';
+ SourceTable = Customer;
layout
{
diff --git a/microsoft/knowledge/web-services/set-required-api-page-properties.md b/microsoft/knowledge/web-services/set-required-api-page-properties.md
index 9bef346..24edc5d 100644
--- a/microsoft/knowledge/web-services/set-required-api-page-properties.md
+++ b/microsoft/knowledge/web-services/set-required-api-page-properties.md
@@ -7,20 +7,20 @@ countries: [w1]
application-area: [all]
---
-# Declare every required property on a PageType = API page
+# Declare API routing properties and an explicit stable version
## Description
-An API page projects a table as an OData v4 / API v2 endpoint, but the platform only publishes that endpoint when the page carries the full set of identifying properties: `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, and a backing `SourceTable`. These properties are what compose the route โ `/api////` โ so omitting any one of them yields a page that compiles yet never surfaces as a usable endpoint, or surfaces at an unexpected address. An LLM that has mostly seen ordinary list/card pages tends to treat `PageType = API` as a cosmetic switch and forgets the identifying metadata, because a normal page needs none of it. This file is remedial precisely because the missing-property failure is silent: there is no runtime error, only an endpoint that clients cannot reach.
+An API page needs `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, and a backing `SourceTable` to define its routed entity. `APIVersion` is different: it is optional at the language level and defaults to `beta`. Omitting it therefore does not mean the page has no version; it publishes under the preview contract. A production integration that intends a stable route should set a `vX.Y` version explicitly rather than rely on that default.
## Best Practice
-On every `PageType = API` page set all six properties explicitly: `APIPublisher` (your publisher tag), `APIGroup` (the logical grouping for related entities), `APIVersion` (a `vX.Y` value such as `'v1.0'`), `EntityName` (singular), `EntitySetName` (plural), and `SourceTable` (the projected table). Expose the record's fields inside a single `field(...)` repeater under `area(content)`. Treat the six properties as a mandatory checklist that travels with the `PageType = API` declaration itself.
+Declare the five routing/entity properties required by the API page and set `APIVersion` explicitly for a stable published contract, for example `'v1.0'`. Expose the record's fields inside a repeater under `area(content)`. Review missing routing metadata as a malformed API definition, but review a missing `APIVersion` as unintended publication under `beta`, not as an unpublished endpoint.
-See sample: `set-required-api-page-properties.good.al`.
+See sample: [`set-required-api-page-properties.good.al`](set-required-api-page-properties.good.al).
## Anti Pattern
-Writing a page with `PageType = API` and a `SourceTable` but leaving out `APIPublisher` and `APIGroup` (and, worse, omitting `SourceTable` entirely). The page compiles, so it looks finished, but the endpoint is malformed: with no publisher and group the route cannot be composed, and the entity is never published where an integration expects it. The detection signal: a `PageType = API` page missing one or more of the six identifying properties.
+Leaving out `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, or `SourceTable` leaves the API definition incomplete. A subtler contract defect is declaring all of those but omitting `APIVersion`: the page is exposed as `beta`, which is valid runtime behavior but not the explicit stable route a production client expects.
-See sample: `set-required-api-page-properties.bad.al`.
+See sample: [`set-required-api-page-properties.bad.al`](set-required-api-page-properties.bad.al).
diff --git a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.good.al b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.good.al
index 97aeb3a..9f1246f 100644
--- a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.good.al
+++ b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.good.al
@@ -1,13 +1,11 @@
-// Additive versioning: v2.0 carries the new shape while v1.0 stays published and
-// unchanged. APIVersion accepts a list, so both contracts are served and
-// existing clients keep working while new clients adopt v2.0.
-page 50354 "WS API Versioning Good"
+// The original page remains the unchanged v1.0 contract.
+page 50354 "Customer API v1"
{
PageType = API;
Caption = 'customer';
APIPublisher = 'contoso';
APIGroup = 'sales';
- APIVersion = 'v2.0', 'v1.0';
+ APIVersion = 'v1.0';
EntityName = 'customer';
EntitySetName = 'customers';
ODataKeyFields = SystemId;
@@ -37,3 +35,41 @@ page 50354 "WS API Versioning Good"
}
}
}
+
+// A separate object carries the changed v2.0 shape.
+page 50356 "Customer API v2"
+{
+ PageType = API;
+ Caption = 'customer';
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v2.0';
+ EntityName = 'customer';
+ EntitySetName = 'customers';
+ ODataKeyFields = SystemId;
+ SourceTable = Customer;
+ DelayedInsert = true;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ field(id; Rec.SystemId)
+ {
+ Caption = 'id';
+ Editable = false;
+ }
+ field(number; Rec."No.")
+ {
+ Caption = 'number';
+ }
+ field(legalName; Rec.Name)
+ {
+ Caption = 'legalName';
+ }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md
index af998ed..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
@@ -7,20 +7,20 @@ countries: [w1]
application-area: [all]
---
-# Version APIs by adding a new APIVersion, not by mutating a published one
+# Version changed API shapes with a new page object
## Description
-Once an API version is published, external clients depend on its exact shape โ the entity name, the set of exposed fields, the key โ as a frozen contract. Changing any of that on the already-published version is a breaking change delivered silently: integrations that worked yesterday fail today with no warning. The platform gives you a clean way to evolve without breaking anyone, because `APIVersion` accepts a *list* of versions on one page. The correct way to change a published API is to add the new version (`'v2.0'`) alongside the existing one (`'v1.0'`) โ or publish a new API page for it โ so both contracts are served side by side and clients migrate on their own schedule. LLMs tend to "fix" an API by editing the live version in place, because in ordinary code you just change what's wrong; this file is remedial because a published API version is an immutable contract in a way ordinary internal code is not.
+Once an API version is published, external clients depend on its exact shape โ entity names, fields, keys, and behavior โ as a stable contract. `APIVersion` can list several versions on one API page, but every listed route is generated from that same page object and therefore exposes the same shape. Adding `'v2.0'` to a page and then changing its fields changes what both `v1.0` and `v2.0` serve. To preserve the v1 shape while introducing a different v2 shape, keep the v1 page unchanged and create a separate page object for v2.
## Best Practice
-When a published API must change shape, keep the old version's contract intact and add the new one to the `APIVersion` list โ `APIVersion = 'v2.0', 'v1.0';`. The page now serves both `v1.0` (unchanged) and `v2.0` (carrying the new shape), so existing clients keep working while new clients adopt `v2.0`. Retire the old version only after consumers have migrated.
+Keep the existing page object and its `APIVersion = 'v1.0'` contract unchanged. Copy the page to a new object ID, set that object's `APIVersion = 'v2.0'`, and make the v2-only shape changes there. A multi-value `APIVersion` list is appropriate only when the exact same page shape is supported under each listed version.
-See sample: `version-apis-by-adding-not-mutating-published-versions.good.al`.
+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 โ renaming its `EntityName` or removing an exposed field โ so the single declared version now serves a different contract than the one clients integrated against. Every consumer of the old shape breaks without notice. The detection signal: a change that renames the entity or removes a field on an existing published `APIVersion` instead of adding a new version to the list.
+Editing the published `v1.0` page in place breaks its clients. So does adding `v2.0` to that same page and assuming subsequent field changes apply only to v2: both routes use one object shape. The detection signal is a breaking shape change without a separate API page object retaining the old version.
-See sample: `version-apis-by-adding-not-mutating-published-versions.bad.al`.
+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.bad.al b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.al
new file mode 100644
index 0000000..9baaa71
--- /dev/null
+++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.al
@@ -0,0 +1,22 @@
+query 50355 "WS Webhook Customer Query"
+{
+ QueryType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'webhookCustomer';
+ EntitySetName = 'webhookCustomers';
+
+ elements
+ {
+ dataitem(customer; Customer)
+ {
+ column(id; SystemId)
+ {
+ }
+ column(displayName; Name)
+ {
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.js b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.js
new file mode 100644
index 0000000..1f624f0
--- /dev/null
+++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.bad.js
@@ -0,0 +1,4 @@
+function receiveBusinessCentralWebhook(request, response) {
+ processNotifications(request.body.value);
+ response.sendStatus(200);
+}
diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.al b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.al
new file mode 100644
index 0000000..0f673cb
--- /dev/null
+++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.al
@@ -0,0 +1,28 @@
+page 50354 "WS Webhook Customer API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'sales';
+ APIVersion = 'v1.0';
+ EntityName = 'webhookCustomer';
+ EntitySetName = 'webhookCustomers';
+ ODataKeyFields = SystemId;
+ SourceTable = Customer;
+
+ layout
+ {
+ area(content)
+ {
+ repeater(records)
+ {
+ field(id; Rec.SystemId)
+ {
+ Editable = false;
+ }
+ field(displayName; Rec.Name)
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.js b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.js
new file mode 100644
index 0000000..f2e42fd
--- /dev/null
+++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.good.js
@@ -0,0 +1,11 @@
+function receiveBusinessCentralWebhook(request, response) {
+ const validationToken = request.query.validationToken;
+
+ if (typeof validationToken === "string") {
+ response.status(200).type("text/plain").send(validationToken);
+ return;
+ }
+
+ processNotifications(request.body.value);
+ response.sendStatus(200);
+}
diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md
new file mode 100644
index 0000000..2aad620
--- /dev/null
+++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md
@@ -0,0 +1,30 @@
+---
+bc-version: [all]
+domain: web-services
+keywords: [webhook, subscription, validationtoken, expirationdatetime, webhook-supported-resources, api-page, sourcetabletemporary, querytype]
+technologies: [al, javascript]
+countries: [w1]
+application-area: [all]
+---
+
+# Verify webhook eligibility and complete every validationToken handshake
+
+## Description
+
+Business Central can subscribe only to eligible API pages, not every endpoint that can be read through an API. Webhooks exclude API queries, temporary API pages, pages with composite OData keys, pages over system tables, and pages over Job Queue Entry (table 472); the environment's `webhookSupportedResources` endpoint is authoritative. Creating and renewing a subscription both call the `notificationUrl` with `validationToken`, and both fail unless the subscriber returns that token in the response body with `200 OK`.
+
+## Best Practice
+
+Before creating a subscription, confirm the resource appears in `webhookSupportedResources` and that a custom endpoint is an API page with a single stable key over an eligible persistent table. Use one validation path that echoes `validationToken` for both create (`POST`) and renew (`PATCH`) handshakes. Track `expirationDateTime` and renew before expiry: online subscriptions expire after three days, while on-premises lifetime defaults to three days and can be changed with `ApiSubscriptionExpiration`.
+
+See samples: [`webhook-eligibility-and-validationtoken-renewal.good.al`](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`](webhook-eligibility-and-validationtoken-renewal.bad.al) and [`webhook-eligibility-and-validationtoken-renewal.bad.js`](webhook-eligibility-and-validationtoken-renewal.bad.js).
+
+## Source
+
+[Working with webhooks](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/api-reference/v2.0/dynamics-subscriptions) and [Update subscriptions](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/api-reference/v2.0/api/dynamics_subscriptions_update).
diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md
new file mode 100644
index 0000000..3f6a221
--- /dev/null
+++ b/microsoft/skills/review/al-appsource-review.md
@@ -0,0 +1,134 @@
+---
+kind: action-skill
+id: al-appsource-review
+version: 1
+title: AL AppSource review
+description: Performs an AL AppSource review against source and app metadata guidance from BCQuality.
+inputs: [pr-diff, file-path, folder-path]
+outputs: [findings-report]
+bc-version: [all]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# AL AppSource review
+
+Reviews AL source and app metadata changes against the `appsource` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
+
+An orchestrator invokes this skill with 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
+
+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
+
+Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context:
+
+- `bc-version` โ the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`.
+- `technologies` โ `[al]`.
+- `countries` โ the countries declared in the consuming app's `app.json`. Default to the orchestrator's configured context; if absent, `unknown`.
+- `application-area` โ the union of application areas declared by the changed objects. Pass the actual set; do not substitute `[all]`. If the area cannot be determined from the changes, the dimension is `unknown`.
+
+Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the finding's `message` MUST name the dimension or dimensions that were unknown.
+
+## Worklist
+
+Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
+
+- The changed files and AL object types โ especially `app.json`, permission-set and profile objects, setup and usage entry points, service-enabled procedures, user-facing pages and reports, and AppSource-facing help metadata.
+- Tokens extracted from the diff that relate to AppSource (`permissionset`, `Assignable`, `Permissions`, `SUPER`, `tabledata`, `execute`, `profile`, `Record Profile`, `Evaluate`, `Date`, `DateTime`, `CurrentDateTime`, `UsageCategory`, `PageType`, `addfirst`, `addlast`, `addbefore`, `addafter`, `ServiceEnabled`, `GuiAllowed`, `Message`, `Confirm`, `StrMenu`, `RunModal`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`).
+
+A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone. When the diff contains no AppSource-related source or metadata changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.
+
+The following targeted checks cover every current `appsource` article across the Microsoft and community layers. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action.
+
+- The app has no assignable permission set covering its setup and usage paths, omits visible object/tabledata grants, or requires `SUPER` for normal operation โ `permission-sets-cover-setup-and-usage-without-super`. Require repository-level app context; one isolated permission-set object cannot prove complete coverage.
+- Install, upgrade, or setup code provisions an app-owned profile through `Record Profile` and `Insert` instead of declaring a `profile` object โ `define-profiles-as-al-objects`.
+- A hard-coded or label-backed formatted string is converted to `Date` with `Evaluate` โ `use-invariant-date-literals`. Do not select this article for variable external input whose format must be validated at runtime.
+- A page extension uses `addbefore` or `addafter` to place a newly added action relative to a specific action owned by another app โ `place-page-extension-actions-with-addfirst-or-addlast`. Do not flag those keywords in layouts or placement relative to an action owned by the same extension.
+- A page or codeunit web-service entry point, including a `[ServiceEnabled]` procedure, contains or reaches `Message`, `Confirm`, `StrMenu`, `Page.RunModal`, or a confirmation-dialog page without an effective non-GUI guard โ `keep-web-service-paths-free-of-ui-calls`. Treat `Message` as suppressed and logged, making it ineffective as a service response; treat the other UI calls as callback-failure risks. Do not treat a controlled `Error` as interactive UI solely because it returns a service fault.
+- A page or report that repository context identifies as a direct user entry point omits `UsageCategory` or sets it to `None` โ `set-usagecategory-on-searchable-entry-points`. Do not select this article based only on object type; exclude supporting parts, dialogs, API pages, and objects intentionally reached through another page.
+- A `DateTime` assignment adds or subtracts a fixed duration to represent an assumed regional offset โ `do-not-hard-code-time-zone-offsets`. Require contextual evidence such as an hour-sized constant, offset-oriented name, or time-zone comment; do not flag deadlines, schedules, or elapsed-time calculations.
+- For BC v27 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent โ `keep-copilot-help-url-to-two-path-levels`.
+
+Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.
+
+When the post-conflict worklist is empty because no applicable AppSource knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable AppSource knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array.
+
+## Action
+
+For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows:
+
+- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the change violates an AppSource submission requirement; otherwise the ceiling is `major`.
+- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape.
+- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present.
+
+Set `confidence` to:
+
+- `high` when the detection is based on an unambiguous pattern match 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, 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.
+
+Outcome selection:
+
+- `completed` โ the skill evaluated every worklist item.
+- `no-knowledge` โ no applicable AppSource knowledge survived filtering.
+- `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.
+
+## Output
+
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"AppSource"`. A populated example:
+
+```json
+{
+ "skill": { "id": "al-appsource-review", "version": 1 },
+ "outcome": "completed",
+ "summary": {
+ "counts": { "blocker": 0, "major": 1, "minor": 0, "info": 0 },
+ "coverage": { "worklist-size": 1, "items-evaluated": 1 }
+ },
+ "findings": [
+ {
+ "id": "microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md",
+ "severity": "major",
+ "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": "app.json",
+ "line": 12
+ },
+ "references": [
+ { "path": "microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md" }
+ ],
+ "confidence": "high",
+ "domain": "AppSource",
+ "suggested-code": "\"help\": \"https://contoso.com/docs/myapp\""
+ }
+ ],
+ "suppressed": []
+}
+```
+
+The empty-corpus case produces:
+
+```json
+{
+ "skill": { "id": "al-appsource-review", "version": 1 },
+ "outcome": "no-knowledge",
+ "summary": {
+ "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 },
+ "coverage": { "worklist-size": 0, "items-evaluated": 0 }
+ },
+ "findings": [],
+ "suppressed": []
+}
+```
diff --git a/microsoft/skills/review/al-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md
index 4238bca..7f30713 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
@@ -43,6 +43,17 @@ Narrow the relevant files to the subset that applies to the changes under review
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone.
+The following targeted checks cover every current `breaking-changes` article:
+
+- A helper or object changes between `local`, `internal`, `protected`, or public access, or a new implementation detail is exposed without a supported-API reason โ `choose-access-modifiers-deliberately`.
+- A public member is removed or replaced without first going through the `[Obsolete]` lifecycle โ `deprecate-public-members-with-the-obsolete-lifecycle`.
+- A published procedure changes parameter count/order/type/name, `var`, return type, or array shape instead of preserving the old signature and adding an overload โ `do-not-change-published-procedure-signatures`.
+- A public procedure/event/interface exposes a credential or other sensitive value through `Text` or an externally callable contract โ `do-not-expose-sensitive-data-through-public-api`.
+- Code already marked obsolete is expanded with new behavior instead of routing new callers to its replacement โ `do-not-modify-code-already-marked-obsolete`.
+- A shipped table field is deleted, renamed, renumbered, or replaced without retaining the original field as `ObsoleteState = Pending` and migrating its data โ `obsolete-table-fields-instead-of-deleting-them`.
+
+For `obsolete-table-fields-instead-of-deleting-them`, compare the baseline ID and name before emitting. When the original field remains under the same ID and name with `ObsoleteState = Pending`, and the replacement uses a new ID, the change follows the rule and must not be flagged.
+
Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.
When the post-conflict worklist is empty because no applicable breaking-changes knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable breaking-changes knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array.
@@ -53,7 +64,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice`
- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the anti-pattern violates a platform-level guarantee. When the file does not make such a claim, the ceiling is `major`.
- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape.
-- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. Repository-wide observations MAY omit `location`.
+- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present.
Set `confidence` to:
@@ -77,7 +88,7 @@ Outcome selection:
## Output
-Output conforms to the DO output contract. A populated example:
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Breaking Changes"`. A populated example:
```json
{
@@ -100,7 +111,8 @@ Output conforms to the DO output contract. A populated example:
"references": [
{ "path": "microsoft/knowledge/breaking-changes/do-not-change-published-procedure-signatures.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Breaking Changes"
},
{
"id": "microsoft/knowledge/breaking-changes/choose-access-modifiers-deliberately.md",
@@ -113,14 +125,15 @@ Output conforms to the DO output contract. A populated example:
"references": [
{ "path": "microsoft/knowledge/breaking-changes/choose-access-modifiers-deliberately.md" }
],
- "confidence": "medium"
+ "confidence": "medium",
+ "domain": "Breaking Changes"
}
],
"suppressed": []
}
```
-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 ba58d70..dc1f1db 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]
@@ -22,6 +22,12 @@ sub-skills:
- microsoft/skills/review/al-interfaces-review.md
- microsoft/skills/review/al-breaking-changes-review.md
- microsoft/skills/review/al-web-services-review.md
+ - microsoft/skills/review/al-testing-review.md
+ - microsoft/skills/review/al-data-modeling-review.md
+ - microsoft/skills/review/al-query-review.md
+ - microsoft/skills/review/al-reporting-review.md
+ - microsoft/skills/review/al-appsource-review.md
+ - microsoft/skills/review/al-telemetry-review.md
---
# AL code review
@@ -30,11 +36,16 @@ 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 (for example, telemetry, testing) are added by updating the `sub-skills` list. The skill does not discover sub-skills implicitly.
+The sub-skills invoked by this skill are those listed in frontmatter `sub-skills`. Additional leaf skills are added by updating the `sub-skills` list. The skill does not discover sub-skills implicitly.
+
+Hosts that orchestrate leaves mechanically SHOULD run
+`tools/Build-SkillIndex.ps1` and resolve this skill by `id: al-code-review`.
+The generated `subSkills` array preserves the frontmatter order and avoids
+host-specific Markdown parsing.
## Relevance
@@ -58,21 +69,27 @@ The worklist is the list of sub-skills judged relevant by the previous step. Eve
### Execution discipline (mandatory)
-The Action step is a sequence of **discrete iterations**, not one combined generation. The contract requires the super-skill to invoke each sub-skill in turn and then perform a self-review pass. Concretely this means:
+The Action step consists of **discrete leaf invocations**, not one combined generation. Invocation scheduling belongs to the orchestrator: independent leaves may run serially or concurrently, but their evaluation contexts and findings-reports remain isolated. Concretely this means:
-- 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.
+- Sub-skills are independent: re-walking the diff once per sub-skill is correct and expected. The output schema accommodates this โ `sub-results` carries one entry per sub-skill, each a complete findings-report, in the frontmatter `sub-skills` order regardless of completion order.
+- When isolated calls are unavailable and the current model cannot finish every leaf within its budget, return `partial` with completed `sub-results` and name the first unevaluated sub-skill in `outcome-reason`. Never silently mark the remaining leaves clean.
### Roll up sub-skill findings
-For each sub-skill in the worklist, executed one at a time per the discipline above:
+For each sub-skill in the worklist:
1. Invoke the sub-skill with the orchestrator's inputs, passing only the subset each sub-skill declares in its `inputs`.
-2. Capture the sub-skill's complete findings-report verbatim and append it to `sub-results`.
-3. If the sub-skill's `outcome` is `failed`, stop here for this sub-skill: its findings are not reliable per the DO contract and MUST NOT be copied into the super-skill's top-level `findings[]` or counted in `summary.counts`.
-4. Otherwise, append each entry from the sub-skill's `findings[]` to the super-skill's top-level `findings[]`, setting `from-sub-skill` to the sub-skill's `skill.id`. For non-citation findings (those whose `id` is a skill-defined slug rather than a reference path), prefix `id` with `:` to prevent collisions across sub-skills. Other finding fields are preserved.
+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.
### Agent self-review pass
@@ -85,11 +102,12 @@ Frame the pass by cross-cutting concerns โ architecture, error handling, resou
For every candidate the agent identifies in this pass:
1. **Validate against BCQuality knowledge.** Check the candidate against the knowledge files the sub-skills have already loaded for this task (visible via their `references` and `suppressed` lists in `sub-results`).
- - If a BCQuality knowledge file matches the candidate, upgrade it to a knowledge-backed finding: cite the file in `references`, set `id` to the file's path, set `from-sub-skill` to the sub-skill that owns that knowledge domain, and merge with or deduplicate against any sub-skill finding that already covers the same concern at the same location.
+ - If a BCQuality knowledge file matches the candidate, upgrade it to a knowledge-backed finding: cite the file in `references`, set `id` to the file's path, set `from-sub-skill` to the sub-skill that owns that knowledge domain, set `domain` to the human-readable label required by that sub-skill's Output contract, and merge with or deduplicate against any sub-skill finding that already covers the same concern at the same location.
- If a BCQuality knowledge file **explicitly contradicts** the candidate (its `## Best Practice` or `## Anti Pattern` says the opposite of what the agent flagged), suppress the candidate and do not surface it.
- Otherwise the candidate has no BCQuality coverage; emit it as a super-skill agent finding.
2. **Emit agent finding.** Per DO's *Agent findings* rules:
- `from-sub-skill: "agent"` (the super-skill itself produced it)
+ - `domain: "Agent"` (the display label for super-skill cross-cutting findings)
- `references: []`
- `id` is a skill-defined slug prefixed with `agent:` (for example, `agent:missing-error-handling-on-http-call`).
- `confidence` capped at `medium`.
@@ -107,12 +125,20 @@ 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 consumer acceptance gate to every nested
+and top-level finding. A leaf's nested report is its accepted exact return or
+its accepted normalized candidate copy; its exact Task return remains the
+separate immutable raw audit payload. Treat an invalid sub-result as failed and
+exclude all of its findings from the top-level rollup. Never reconstruct it
+into a success-shaped report or perform normalization beyond DO's bounded
+exception.
+
## Output
Output conforms to the DO output contract, extended with `sub-results` and `skipped-sub-skills`. A populated example โ both leaves ran, each produced findings:
@@ -127,36 +153,38 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip
},
"findings": [
{
- "id": "microsoft/knowledge/performance/filter-before-find.md",
+ "id": "microsoft/knowledge/performance/apply-filters-before-iterating.md",
"severity": "major",
- "message": "FindSet is called on a record variable without any prior SetRange/SetFilter. This forces a full-table scan.",
+ "message": "The Country/Region Code predicate is evaluated inside the loop instead of with SetRange before FindSet, so every row crosses the database boundary.",
"location": {
"file": "src/Sales/PostingRoutines.Codeunit.al",
"line": 140,
"range": { "start-line": 140, "end-line": 144 }
},
"references": [
- { "path": "microsoft/knowledge/performance/filter-before-find.md" }
+ { "path": "microsoft/knowledge/performance/apply-filters-before-iterating.md" }
],
"confidence": "high",
- "from-sub-skill": "al-performance-review"
+ "from-sub-skill": "al-performance-review",
+ "domain": "Performance"
},
{
- "id": "community/knowledge/performance/call-setloadfields-before-filters.md",
+ "id": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md",
"severity": "minor",
- "message": "SetLoadFields is called after SetRange. Per the referenced guidance the call must come before filters to be folded into the query plan.",
+ "message": "The loop reads only a small subset of fields from a wide table without SetLoadFields, transferring every column for each row.",
"location": {
"file": "src/Sales/PostingRoutines.Codeunit.al",
"line": 152
},
"references": [
- { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" }
+ { "path": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md" }
],
"confidence": "high",
- "from-sub-skill": "al-performance-review"
+ "from-sub-skill": "al-performance-review",
+ "domain": "Performance"
},
{
- "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md",
+ "id": "microsoft/knowledge/security/secrettext-for-credentials.md",
"severity": "blocker",
"message": "A bearer token is declared as a Text parameter and passed through the HTTP request path as plain text. The referenced guidance requires credentials to flow as SecretText end-to-end.",
"location": {
@@ -165,24 +193,26 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip
"range": { "start-line": 85, "end-line": 89 }
},
"references": [
- { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" }
+ { "path": "microsoft/knowledge/security/secrettext-for-credentials.md" }
],
"confidence": "high",
- "from-sub-skill": "al-security-review"
+ "from-sub-skill": "al-security-review",
+ "domain": "Security"
},
{
- "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md",
+ "id": "microsoft/knowledge/security/secrets-isolated-storage.md",
"severity": "minor",
- "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.",
+ "message": "A setup table stores an API key in an ordinary Text field, exposing it through table reads and exports. Persist it in IsolatedStorage instead.",
"location": {
- "file": "src/Integration/ApiClient.Codeunit.al",
- "line": 201
+ "file": "src/Integration/ExternalServiceSetup.Table.al",
+ "line": 12
},
"references": [
- { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" }
+ { "path": "microsoft/knowledge/security/secrets-isolated-storage.md" }
],
"confidence": "medium",
- "from-sub-skill": "al-security-review"
+ "from-sub-skill": "al-security-review",
+ "domain": "Security"
},
{
"id": "agent:missing-error-handling-on-http-client",
@@ -195,7 +225,8 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip
},
"references": [],
"confidence": "medium",
- "from-sub-skill": "agent"
+ "from-sub-skill": "agent",
+ "domain": "Agent"
}
],
"suppressed": [],
@@ -209,31 +240,33 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip
},
"findings": [
{
- "id": "microsoft/knowledge/performance/filter-before-find.md",
+ "id": "microsoft/knowledge/performance/apply-filters-before-iterating.md",
"severity": "major",
- "message": "FindSet is called on a record variable without any prior SetRange/SetFilter. This forces a full-table scan.",
+ "message": "The Country/Region Code predicate is evaluated inside the loop instead of with SetRange before FindSet, so every row crosses the database boundary.",
"location": {
"file": "src/Sales/PostingRoutines.Codeunit.al",
"line": 140,
"range": { "start-line": 140, "end-line": 144 }
},
"references": [
- { "path": "microsoft/knowledge/performance/filter-before-find.md" }
+ { "path": "microsoft/knowledge/performance/apply-filters-before-iterating.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Performance"
},
{
- "id": "community/knowledge/performance/call-setloadfields-before-filters.md",
+ "id": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md",
"severity": "minor",
- "message": "SetLoadFields is called after SetRange. Per the referenced guidance the call must come before filters to be folded into the query plan.",
+ "message": "The loop reads only a small subset of fields from a wide table without SetLoadFields, transferring every column for each row.",
"location": {
"file": "src/Sales/PostingRoutines.Codeunit.al",
"line": 152
},
"references": [
- { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" }
+ { "path": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Performance"
}
],
"suppressed": []
@@ -247,7 +280,7 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip
},
"findings": [
{
- "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md",
+ "id": "microsoft/knowledge/security/secrettext-for-credentials.md",
"severity": "blocker",
"message": "A bearer token is declared as a Text parameter and passed through the HTTP request path as plain text. The referenced guidance requires credentials to flow as SecretText end-to-end.",
"location": {
@@ -256,22 +289,24 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip
"range": { "start-line": 85, "end-line": 89 }
},
"references": [
- { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" }
+ { "path": "microsoft/knowledge/security/secrettext-for-credentials.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Security"
},
{
- "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md",
+ "id": "microsoft/knowledge/security/secrets-isolated-storage.md",
"severity": "minor",
- "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.",
+ "message": "A setup table stores an API key in an ordinary Text field, exposing it through table reads and exports. Persist it in IsolatedStorage instead.",
"location": {
- "file": "src/Integration/ApiClient.Codeunit.al",
- "line": 201
+ "file": "src/Integration/ExternalServiceSetup.Table.al",
+ "line": 12
},
"references": [
- { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" }
+ { "path": "microsoft/knowledge/security/secrets-isolated-storage.md" }
],
- "confidence": "medium"
+ "confidence": "medium",
+ "domain": "Security"
}
],
"suppressed": []
@@ -280,7 +315,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
{
@@ -310,4 +347,3 @@ The empty-corpus case โ BCQuality's state until knowledge files land โ rolls
]
}
```
-
diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md
new file mode 100644
index 0000000..05dcd0b
--- /dev/null
+++ b/microsoft/skills/review/al-data-modeling-review.md
@@ -0,0 +1,134 @@
+---
+kind: action-skill
+id: al-data-modeling-review
+version: 1
+title: AL data-modeling review
+description: Performs an AL data-modeling review against guidance from BCQuality.
+inputs: [pr-diff, file-path, folder-path]
+outputs: [findings-report]
+bc-version: [all]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# AL data-modeling review
+
+Reviews AL source changes against the `data-modeling` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
+
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design โ they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, or audit fields. The skill returns `not-applicable` when none of those apply.
+
+## Source
+
+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
+
+Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context:
+
+- `bc-version` โ the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`.
+- `technologies` โ `[al]`.
+- `countries` โ the countries declared in the consuming app's `app.json`. Default to the orchestrator's configured context; if absent, `unknown`.
+- `application-area` โ the union of application areas declared by the changed objects. Pass the actual set; do not substitute `[all]`. If the area cannot be determined from the changes, the dimension is `unknown`.
+
+Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the finding's `message` MUST name the dimension or dimensions that were unknown.
+
+## Worklist
+
+Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
+
+- The changed AL object names and types โ especially `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, and document or journal lines that reference a master.
+- The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, and posting validation.
+- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`).
+
+A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone. When the diff contains no data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.
+
+The following targeted checks cover every current `data-modeling` article. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action.
+
+- A `* Setup` table or its page changes singleton structure, uses a nonblank or generated key, permits insert/delete, uses a List page, or does not ensure the blank-keyed row exists โ `setup-table-is-a-singleton`.
+- A custom master table changes its primary key, `No.`/`No. Series` fields, or `OnInsert` without assigning a blank `No.` from setup through a number series โ `master-table-no-from-number-series-in-oninsert`.
+- BC v22 or later code introduces or retains `NoSeriesManagement`, `InitSeries`, `SelectSeries`, or `SetSeries`, or number assignment/manual-entry checks do not use codeunit `"No. Series"` methods such as `GetNextNo`, `IsManual`, or `TestManual` โ `use-no-series-codeunit-not-noseriesmanagement`.
+- A master gains or changes `Blocked`, or a document line, journal line, reference-field `OnValidate`, or posting routine uses that master without `TestField(Blocked, false)` at the point of use; also cue when the check is placed only in the master's own triggers โ `check-blocked-in-referencing-code-not-in-master`.
+- A master table adds or changes `Last Date Modified`, `OnModify`, or `OnRename`, but the non-editable field is not assigned `Today()` in both triggers โ `set-last-date-modified-in-onmodify-and-onrename`.
+- A `tableextension` appends a conditional `TableRelation` as if it overrides an earlier unconditional relation, or relation branches are otherwise designed without accounting for additive top-down evaluation โ `table-relation-extensions-are-additive-and-top-down`.
+- A `Media` or `MediaSet` field is assigned directly between different table types or different field IDs instead of registering each shared item with `MediaSet.Insert` โ `share-mediaset-items-with-insert-not-field-assignment`.
+
+Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.
+
+When the post-conflict worklist is empty because no applicable data-modeling knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable data-modeling knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array.
+
+## Action
+
+For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows:
+
+- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the model can create ambiguous setup state, incompatible business identifiers, or silently stale synchronization data; otherwise the ceiling is `major`.
+- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape.
+- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present.
+
+Set `confidence` to:
+
+- `high` when the detection is based on an unambiguous pattern match (object type, field, key, trigger, or API name).
+- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`.
+- `low` when the finding is an advisory derived only from applicability.
+
+After evaluating each worklist entry, also consider whether the diff exhibits a data-modeling defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain โ emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material data-modeling defect a knowledgeable BC reviewer would agree is wrong โ steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly data modeling; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate โ if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
+
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add `InsertAllowed = false` or `DeleteAllowed = false`; replace `WorkDate()` with `Today()`; add the same audit-field assignment to `OnRename`; or replace an obsolete number-series codeunit declaration). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+
+Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
+
+Outcome selection:
+
+- `completed` โ the skill evaluated every worklist item.
+- `no-knowledge` โ no applicable data-modeling knowledge survived filtering.
+- `not-applicable` โ the diff touches no setup/master table, page, key, numbering, block-check, or audit-field surface.
+- `partial` โ a budget was hit before the worklist was exhausted.
+- `failed` โ an unrecoverable error occurred.
+
+## Output
+
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Data Modeling"`. A populated example:
+
+```json
+{
+ "skill": { "id": "al-data-modeling-review", "version": 1 },
+ "outcome": "completed",
+ "summary": {
+ "counts": { "blocker": 0, "major": 1, "minor": 0, "info": 0 },
+ "coverage": { "worklist-size": 1, "items-evaluated": 1 }
+ },
+ "findings": [
+ {
+ "id": "microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md",
+ "severity": "major",
+ "message": "The table updates Last Date Modified in OnModify but not OnRename, so renaming the primary key leaves the audit date stale and can hide the record from incremental integrations.",
+ "location": {
+ "file": "src/LoyaltyMember.Table.al",
+ "line": 74
+ },
+ "references": [
+ { "path": "microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md" }
+ ],
+ "confidence": "high",
+ "domain": "Data Modeling",
+ "suggested-code": "trigger OnRename()\nbegin\n \"Last Date Modified\" := Today();\nend;"
+ }
+ ],
+ "suppressed": []
+}
+```
+
+The empty-corpus case produces:
+
+```json
+{
+ "skill": { "id": "al-data-modeling-review", "version": 1 },
+ "outcome": "no-knowledge",
+ "summary": {
+ "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 },
+ "coverage": { "worklist-size": 0, "items-evaluated": 0 }
+ },
+ "findings": [],
+ "suppressed": []
+}
+```
diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md
index 91228aa..56bc0fe 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,11 @@ 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.
## Relevance
@@ -38,11 +38,21 @@ Discard files that are not applicable. Retain conditionally applicable files (an
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
- The changed AL object names and types โ especially codeunits that post or validate, tables and table extensions with `OnValidate` triggers, and any procedure that raises errors or orchestrates a batch over records.
-- The changed procedures and triggers, weighted toward `OnValidate`/`OnInsert`/`OnModify` triggers, posting and validation routines, and procedures attributed with `[ErrorBehavior(...)]`.
-- Tokens extracted from the diff that relate to error surfacing and diagnostics (`Error`, `ErrorInfo`, `Title`, `Message`, `DetailedMessage`, `AddAction`, `AddNavigationAction`, `RecordId`, `PageNo`, `ErrorBehavior`, `Collect`, `HasCollectedErrors`, `GetCollectedErrors`, `ClearCollectedErrors`, `ErrorType`, `Internal`, `Client`).
+- The changed procedures and triggers, weighted toward `OnValidate`/`OnInsert`/`OnModify` triggers, posting and validation routines, and procedures attributed with `[ErrorBehavior(...)]` or `[TryFunction]`.
+- Tokens extracted from the diff that relate to error surfacing and diagnostics (`Error`, `ErrorInfo`, `FieldError`, `TestField`, `Title`, `Message`, `DetailedMessage`, `AddAction`, `AddNavigationAction`, `RecordId`, `PageNo`, `ErrorBehavior`, `Collect`, `HasCollectedErrors`, `GetCollectedErrors`, `ClearCollectedErrors`, `ErrorType`, `Internal`, `Client`, `TryFunction`, `GetLastErrorText`, Boolean assignment).
+- Resolve changed standalone call targets; when the target declaration has `[TryFunction]`, worklist the ignored-return rule even if the declaration itself is unchanged. Only assignment and conditional use activate try semantics.
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone.
+The following targeted checks cover every current `error-handling` article:
+
+- `[ErrorBehavior(ErrorBehavior::Collect)]`, `ErrorInfo.Collectible`, `HasCollectedErrors`, `GetCollectedErrors`, or `ClearCollectedErrors` is added or changed, especially when errors are collected without later surfacing/clearing them โ `collect-validation-errors-with-errorbehavior`.
+- Developer-only invariant text is raised with default client visibility, or a user-actionable validation is hidden as `ErrorType::Internal` โ `errortype-internal-vs-client-for-diagnostics`.
+- `FieldError` receives a complete capitalized sentence, repeats the field caption/value, or ends the predicate with punctuation โ `fielderror-default-message-logic`.
+- An unguarded `FieldError` is used as though it performed a comparison, or `TestField` is forced onto a complex rule needing a tailored predicate โ `fielderror-vs-testfield`.
+- A resolved call target is marked `[TryFunction]` but the call is a standalone statement whose Boolean result is ignored โ `ignored-tryfunction-return-disables-try-semantics`. This call-site rule supersedes the performance TryFunction article unless writes and rollback expectations are also visible.
+- A plain `Error` represents a known actionable correction that can be expressed through `ErrorInfo` actions/navigation, or an `ErrorInfo` omits the context needed for that action โ `prefer-errorinfo-for-actionable-errors`.
+
Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.
When the post-conflict worklist is empty because no applicable error-handling knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable error-handling knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array.
@@ -53,7 +63,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice`
- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the anti-pattern violates a platform-level guarantee. When the file does not make such a claim, the ceiling is `major`.
- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape.
-- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. Repository-wide observations MAY omit `location`.
+- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present.
Set `confidence` to:
@@ -77,7 +87,7 @@ Outcome selection:
## Output
-Output conforms to the DO output contract. A populated example:
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Error Handling"`. A populated example:
```json
{
@@ -100,7 +110,8 @@ Output conforms to the DO output contract. A populated example:
"references": [
{ "path": "microsoft/knowledge/error-handling/prefer-errorinfo-for-actionable-errors.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Error Handling"
},
{
"id": "microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md",
@@ -113,14 +124,15 @@ Output conforms to the DO output contract. A populated example:
"references": [
{ "path": "microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md" }
],
- "confidence": "medium"
+ "confidence": "medium",
+ "domain": "Error Handling"
}
],
"suppressed": []
}
```
-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 0fe9479..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`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`).
+- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `InternalEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `GlobalVarAccess`, `Isolated`, `local`, `internal`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`, `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.
@@ -51,18 +51,21 @@ When the post-conflict worklist is empty because no applicable events knowledge
The following targeted checks map diff signals to specific `events` articles. Treat each as a candidate-selection cue: when the signal appears in the changed code, add the named article to the worklist and evaluate it in Action.
-- `IsHandled` raised without an immediately preceding `IsHandled := false;`, or one `IsHandled` variable reused across several raises with no reset between them โ `initialize-ishandled-to-false-before-publishing`.
+- An `IsHandled` value that can carry over as `true` (reused after an earlier raise, re-entered on a later loop iteration, input/global/field, or otherwise seeded) is passed to a publisher without a reset โ `reset-ishandled-only-when-the-value-can-carry-over`. Do not match one non-looping raise using a fresh local Boolean, or a later raise reached only after a semantically valid `if IsHandled then exit;` proves the value is false.
- `if IsHandled then exit;` in a routine that also raises a paired `OnAfterโฆ` event later, so the after-event is skipped whenever the call is handled โ `preserve-onafter-execution-when-ishandled-skips-the-body`.
-- A parameter added before existing parameters on a changed event signature instead of appended at the end โ `add-new-event-parameters-at-the-end`.
+- Any parameter added to a public Business/Integration event procedure, regardless of position; do not flag additions or reordering on `local`/`internal` publishers merely because a new parameter was not appended โ `add-new-event-parameters-at-the-end`.
+- A shipped Business/Integration event renamed or removed, or an existing parameter renamed, removed, retyped, or changed to/from `var`, based on the mistaken assumption that `local` or `internal` prevents dependent subscription; parameter order alone is not a subscriber-contract violation โ `treat-local-and-internal-events-as-subscriber-contracts`.
+- Any change to `IncludeSender` or `GlobalVarAccess` on a shipped event at any target version, or to `Isolated` on BC20/runtime 9.0 or later, including a change intended to modernize the publisher โ `do-not-change-shipped-event-attribute-flags`.
- Publisher names that do not encode firing position (`OnBefore`/`OnAfter` at the boundaries, `OnOnBefore`/`OnAfter` mid-routine) โ `name-events-by-publisher-position`.
- Two consecutive `OnBefore`/`OnAfter` raises with no logic between them, or a near-duplicate event differing only by an extra parameter โ `prefer-reusing-or-extending-existing-events`.
- An event raised between `repeat` and `until` inside a record loop โ `do-not-publish-events-inside-loops`.
- A `temporary` record event parameter whose name does not start with `Temp` โ `prefix-temporary-record-event-parameters-with-temp`.
- Abbreviated event parameter names (`SalesHdr`, `DocNo`, `Amt`) instead of full table names and spelled-out values โ `name-event-parameters-without-abbreviations`.
-- `[IntegrationEvent(true, โฆ)]` (`IncludeSender`) on a codeunit event used only to expose the publisher, where `this` could be passed as a typed `Sender` parameter (Business Central 2024 release wave 2 and later) โ `prefer-this-over-includesender-in-codeunit-events`.
+- `[IntegrationEvent(true, โฆ)]` (`IncludeSender`) on a newly added codeunit event used only to expose the publisher, where `this` could be passed as a typed `Sender` parameter (Business Central 2024 release wave 2 and later) โ `prefer-this-over-includesender-in-codeunit-events`.
- A `RecordRef` event parameter, or a passed-through `xRec`, where a concrete typed record fits โ `avoid-loosely-typed-event-parameters`.
- A `var IsHandled` added to a pre-existing event rather than introduced through a new `OnBefore` publisher โ `do-not-add-ishandled-to-an-existing-event`.
- An `if IsHandled then exit;` whose skipped body performs posting, ledger-entry creation, number-series consumption, or integrity/permission validation โ `do-not-bypass-critical-operations-with-ishandled`.
+- 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
@@ -70,7 +73,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice`
- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the anti-pattern violates a platform-level guarantee. When the file does not make such a claim, the ceiling is `major`.
- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape.
-- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. Repository-wide observations MAY omit `location`.
+- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present.
Set `confidence` to:
@@ -94,7 +97,7 @@ Outcome selection:
## Output
-Output conforms to the DO output contract. A populated example:
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Events"`. A populated example:
```json
{
@@ -117,7 +120,8 @@ Output conforms to the DO output contract. A populated example:
"references": [
{ "path": "microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Events"
},
{
"id": "microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.md",
@@ -130,14 +134,15 @@ Output conforms to the DO output contract. A populated example:
"references": [
{ "path": "microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Events"
}
],
"suppressed": []
}
```
-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-interfaces-review.md b/microsoft/skills/review/al-interfaces-review.md
index c859b75..f5d65cd 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`, `implements`, `Implementation`, `DefaultImplementation`, `UnknownValueImplementation`, `enum`, `Extensible`, `Interface`, `case`, and the `case of` anti-pattern signal โ a `case` over an enum value whose branches choose between variant computations).
+- Tokens extracted from the diff that relate to interfaces and enum-backed implementation (`interface`, `extends`, `implements`, `Implementation`, `DefaultImplementation`, `UnknownValueImplementation`, `enum`, `Extensible`, `Interface`, `case`, and the `case of` anti-pattern signal โ a `case` over an enum value whose branches choose between variant computations).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone.
@@ -47,13 +47,23 @@ Once the candidate worklist is known, resolve layer-precedence conflicts per REA
When the post-conflict worklist is empty because no applicable interfaces knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable interfaces knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array.
+### Interface-compatibility checks
+
+The following targeted checks map diff signals to specific `interfaces` articles. Treat each as a candidate-selection cue: when the signal appears in the changed code, add the named article to the worklist and evaluate it in Action.
+
+- `DefaultImplementation` used as the only fallback where a persisted ordinal may no longer match any declared enum value, or a persisted enum lacks `UnknownValueImplementation` on BC18 or later โ `handle-unknown-enum-ordinals-with-unknownvalueimplementation`.
+- A method added directly to an interface that exists in the baseline, instead of adding a BC25+ interface that `extends` it or a versioned sibling for older targets โ `extend-published-interfaces-dont-edit-them`.
+- A declared enum value with no `Implementation` and no enum-level `DefaultImplementation` โ `set-defaultimplementation-on-enum`.
+
+For `set-defaultimplementation-on-enum`, inspect the complete containing enum before emitting. An enum-level `DefaultImplementation = = ;` conclusively covers every declared value that omits its own `Implementation`; do not flag such a value and do not replace the intentional fallback with a per-value mapping.
+
## Action
For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows:
- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the anti-pattern violates a platform-level guarantee. When the file does not make such a claim, the ceiling is `major`.
- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape.
-- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. Repository-wide observations MAY omit `location`.
+- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present.
Set `confidence` to:
@@ -77,7 +87,7 @@ Outcome selection:
## Output
-Output conforms to the DO output contract. A populated example:
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Interfaces"`. A populated example:
```json
{
@@ -100,7 +110,8 @@ Output conforms to the DO output contract. A populated example:
"references": [
{ "path": "microsoft/knowledge/interfaces/prefer-interface-over-case-branching.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Interfaces"
},
{
"id": "microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md",
@@ -113,7 +124,8 @@ Output conforms to the DO output contract. A populated example:
"references": [
{ "path": "microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Interfaces"
}
],
"suppressed": []
diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md
index 09bef27..7829d70 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
@@ -38,11 +38,29 @@ Discard files that are not applicable. Retain conditionally applicable files (an
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
- The changed AL object names and types โ especially tables, pages with SourceTable bindings, reports, queries, and codeunits performing record iteration.
-- The changed procedures and triggers, weighted toward those that perform loops, Find/FindSet/FindFirst calls, CalcFields, CalcSums, FlowField access, or cross-table navigation.
-- Tokens extracted from the diff that relate to data access and hot-path costs (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `CalcSums`).
+- The changed procedures and triggers, weighted toward those that perform loops, Find/FindSet/FindFirst calls, CalcFields, SetAutoCalcFields, CalcSums, FlowField access, Commit calls, checkpoint helpers, record copying, RecordRef conversion, Modify/Delete calls, or cross-table navigation.
+- Tokens extracted from the diff that relate to data access, hot-path costs, and background scheduling (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`, `FlowField`, `Visible`, `Job Queue Entry`, `Job Queue Category Code`, `Confirm`, `RunModal`, `GuiAllowed`, `TryFunction`, `Codeunit.Run`, `HttpClient`, `Status`, `On Hold`, `stop request`, `TaskScheduler.CreateTask`, `TaskScheduler.TaskExists`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone.
+Apply these targeted cues even when simple token overlap would rank the article below the worklist cutoff:
+
+- Worklist `use-setautocalcfields-for-per-row-flowfields.md` when a record loop calls `CalcFields`, or when every row reads the same FlowField for a comparison, branch, or per-record action. Worklist `calcsums-instead-of-calcfields-in-loop.md` instead when the loop only accumulates one set total.
+- Worklist `hidden-flowfields-still-calculate-before-bc26-opt-in.md` when a page control directly sources a FlowField and sets `Visible = false` or a visibility expression. Suppress it when the target is known to have BC26's **Calculate only visible FlowFields** feature enabled, or when the FlowField is cheap and intentionally preloaded.
+- Worklist `avoid-commit-inside-loops.md` 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.
+
+These targeted inclusions and exclusions override generic token overlap. Do not retain an excluded article solely because the diff contains one of its keywords.
+
Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.
When the post-conflict worklist is empty because no applicable performance knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable performance knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array.
@@ -53,7 +71,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice`
- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the anti-pattern violates a platform-level guarantee (for example, documented query timeouts or transaction size limits). When the file does not make such a claim, the ceiling is `major`.
- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape.
-- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. Repository-wide observations MAY omit `location`.
+- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present.
Set `confidence` to:
@@ -63,7 +81,7 @@ Set `confidence` to:
After evaluating each worklist entry, also consider whether the diff exhibits a performance defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain โ emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material performance defect a knowledgeable BC reviewer would agree is wrong โ steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly performance; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate โ if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
-For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
@@ -77,7 +95,7 @@ Outcome selection:
## Output
-Output conforms to the DO output contract. A populated example:
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Performance"`. A populated example:
```json
{
@@ -89,38 +107,40 @@ Output conforms to the DO output contract. A populated example:
},
"findings": [
{
- "id": "microsoft/knowledge/performance/filter-before-find.md",
+ "id": "microsoft/knowledge/performance/apply-filters-before-iterating.md",
"severity": "major",
- "message": "FindSet is called on a record variable without any prior SetRange/SetFilter. This forces a full-table scan.",
+ "message": "The Country/Region Code predicate is evaluated inside the loop instead of with SetRange before FindSet, so every row crosses the database boundary.",
"location": {
"file": "src/Sales/PostingRoutines.Codeunit.al",
"line": 140,
"range": { "start-line": 140, "end-line": 144 }
},
"references": [
- { "path": "microsoft/knowledge/performance/filter-before-find.md" }
+ { "path": "microsoft/knowledge/performance/apply-filters-before-iterating.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Performance"
},
{
- "id": "community/knowledge/performance/call-setloadfields-before-filters.md",
+ "id": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md",
"severity": "minor",
- "message": "SetLoadFields is called after SetRange. Per the referenced guidance the call must come before filters to be folded into the query plan.",
+ "message": "The loop reads only a small subset of fields from a wide table without SetLoadFields, transferring every column for each row.",
"location": {
"file": "src/Sales/PostingRoutines.Codeunit.al",
"line": 152
},
"references": [
- { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" }
+ { "path": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Performance"
}
],
"suppressed": []
}
```
-The empty-corpus case โ BCQuality's state until performance knowledge files land โ produces:
+When no applicable performance knowledge is available, the report is:
```json
{
@@ -134,4 +154,3 @@ The empty-corpus case โ BCQuality's state until performance knowledge files la
"suppressed": []
}
```
-
diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md
index 4ef87e3..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
@@ -38,10 +38,17 @@ Discard files that are not applicable. Retain conditionally applicable files (an
Narrow the relevant files to the subset that applies to the changes under review. Exclude test codeunits, test libraries, test helper code, files under test/Test/Tests paths, and objects with `Subtype = Test`; test data is synthetic and does not ship to customers. For each relevant file, compute overlap against:
- The changed AL object names and types โ especially tables and tableextensions (for `DataClassification` on fields), codeunits that call `Error`, `Session.LogMessage`, or `FeatureTelemetry`, codeunits performing outgoing HTTP requests with customer data, migration codeunits, and objects reading or writing `IsolatedStorage`.
-- The changed procedures and triggers, weighted toward those that call `Error`, `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`, `FeatureTelemetry.LogUsage`/`LogUptake`/`LogError`, `HttpClient.Post`/`Get`, `IsolatedStorage.Set`/`SetEncrypted`/`Get`, or `PrivacyNotice.GetPrivacyNoticeApprovalState`.
-- Tokens extracted from the diff that relate to privacy (`DataClassification`, `CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `SystemMetadata`, `ToBeClassified`, `PrivacyNotice`, `GetLastErrorText`, `TelemetryScope`, `FeatureTelemetry`, `CustomDimensions`, `LogUsage`, `LogUptake`, `LogError`, `HybridSL`, `HybridGP`, `HybridBC`).
+- The changed procedures and triggers, weighted toward those that call `Error`, construct `ErrorInfo`, call `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`/`GetLastErrorCallStack`, `FeatureTelemetry.LogUsage`/`LogUptake`/`LogError`, `HttpClient.Post`/`Get`, `IsolatedStorage.Set`/`SetEncrypted`/`Get`, or `PrivacyNotice.GetPrivacyNoticeApprovalState`.
+- Tokens extracted from the diff that relate to privacy (`DataClassification`, `CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `SystemMetadata`, `ToBeClassified`, `PrivacyNotice`, `ErrorInfo`, `GetLastErrorText`, `GetLastErrorCallStack`, `TelemetryScope`, `FeatureTelemetry`, `CustomDimensions`, `LogUsage`, `LogUptake`, `LogError`, `ErrorText`, `ErrorCallStack`, `alErrorText`, `alErrorCallStack`, `HybridSL`, `HybridGP`, `HybridBC`).
+- Treat `ErrorInfo.Message`, `ErrorInfo.DataClassification`, `ErrorInfo.ErrorType`, and `ErrorInfo.DetailedMessage` as qualified member signals: accept a call or assignment only when symbol resolution proves that its receiver expression or variable has type `ErrorInfo`. Normalize those accesses to `errorinfo-message`, `errorinfo-dataclassification`, `errorinfo-errortype`, and `errorinfo-detailedmessage` retrieval tokens. Bare `Message` or `DataClassification` tokens MUST NOT trigger this article; do not emit the qualified tokens for `Message(...)` dialog calls, table or table-field `DataClassification` properties, or similarly named members on other types. Resolve the receiver's declaration from the containing object when it is outside the changed hunk.
+- Worklist ErrorInfo privacy guidance only from those typed `ErrorInfo` member tokens or from construction of an `ErrorInfo` value. For every `FeatureTelemetry.LogError`, inspect the dedicated error text and call-stack arguments in addition to explicit custom dimensions.
-A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone.
+A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Apply the topic-specific gates above after this overlap check; in particular, bare `Message` and `DataClassification` tokens cannot admit ErrorInfo guidance. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone.
+
+Apply API ownership before fuzzy ranking:
+
+- A `Session.LogMessage` message built with `StrSubstNo` or concatenation from customer, employee, filename, document, or other identifying values belongs to `no-pii-in-telemetry-message-string.md`.
+- `avoid-strsubstno-prebuild-before-error.md` applies only when `StrSubstNo` or concatenation supplies the first argument to `Error(...)`. Never apply it to `Session.LogMessage`, `FeatureTelemetry`, or another telemetry API.
Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.
@@ -53,7 +60,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice`
- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the anti-pattern violates a platform-level guarantee (for example, documented telemetry-classification rules or GDPR-adjacent data-handling requirements). When the file does not make such a claim, the ceiling is `major`.
- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape.
-- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. Repository-wide observations MAY omit `location`.
+- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present.
Set `confidence` to:
@@ -61,9 +68,9 @@ Set `confidence` to:
- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`.
- `low` when the finding is an advisory derived only from applicability.
-After evaluating each worklist entry, also consider whether the diff exhibits a privacy defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain โ emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material privacy defect a knowledgeable BC reviewer would agree is wrong โ steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly privacy; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate โ if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
+This leaf emits only knowledge-backed privacy findings. Do NOT emit reference-less `agent:` findings in this domain: online evaluation shows the privacy agent-finding channel yields almost no accepted findings and a high volume of dismissed noise, so a privacy concern that no worklist knowledge file covers is omitted here rather than emitted with `references: []`. When you spot a material privacy defect no article covers, the durable fix is to add a knowledge article in BCQuality (per the online-eval self-improvement loop) so this leaf can cite it โ not a one-off reference-less finding. Before treating a candidate as uncovered, check the worklist for a knowledge file that matches it; if one exists, emit it as a knowledge-backed finding. See `skills/do.md` for the full contract.
-For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
@@ -77,7 +84,7 @@ Outcome selection:
## Output
-Output conforms to the DO output contract. A populated example:
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Privacy"`. A populated example:
```json
{
@@ -89,21 +96,21 @@ Output conforms to the DO output contract. A populated example:
},
"findings": [
{
- "id": "microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md",
+ "id": "microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md",
"severity": "major",
- "message": "Error receives a pre-built Text produced by StrSubstNo with customer name and email as arguments. Per the referenced guidance the platform cannot classify or strip PII from an opaque Text and will export the full message to telemetry.",
+ "message": "The new Customer E-Mail table field has no DataClassification property, leaving personal data unclassified.",
"location": {
- "file": "src/Sales/CustomerValidation.Codeunit.al",
+ "file": "src/Sales/Customer.TableExt.al",
"line": 64,
"range": { "start-line": 60, "end-line": 64 }
},
"references": [
- { "path": "microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md" }
+ { "path": "microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Privacy"
}
],
"suppressed": []
}
```
-
diff --git a/microsoft/skills/review/al-query-review.md b/microsoft/skills/review/al-query-review.md
new file mode 100644
index 0000000..e97a20a
--- /dev/null
+++ b/microsoft/skills/review/al-query-review.md
@@ -0,0 +1,58 @@
+---
+kind: action-skill
+id: al-query-review
+version: 1
+title: AL Query review
+description: Reviews AL Query objects and Query instance usage against BCQuality guidance.
+inputs: [pr-diff, file-path, folder-path]
+outputs: [findings-report]
+bc-version: [all]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# AL Query review
+
+Reviews AL source changes against the `query` knowledge domain in BCQuality. This is a leaf action skill composed by `al-code-review`.
+
+## Source
+
+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
+
+Apply READ's frontmatter matching rules against the task context. Use the target version from `app.json` when available and `[al]` for technologies. Retain conditionally applicable files only when configured; cap resulting confidence at `medium` and name every unknown dimension in the finding message.
+
+Return `not-applicable` when the input contains no Query object declaration and no Query variable method call.
+
+## Worklist
+
+Match relevant entries against changed `query` objects, variables typed as `Query`, and the tokens `QueryType`, `dataitem`, `column`, `DataItemLink`, `SqlJoinType`, `SetFilter`, `SetRange`, `Open`, `Read`, `Close`, and `Clear`.
+
+The following targeted checks cover every current `query` article:
+
+- 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 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, 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()`, 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`.
+
+## Output
+
+Output conforms to the DO findings-report contract. Every finding this skill emits MUST set `findings[].domain` to `"Query"`.
diff --git a/microsoft/skills/review/al-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-security-review.md b/microsoft/skills/review/al-security-review.md
index 1e72447..00e8d10 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,10 +39,17 @@ Narrow the relevant files to the subset that applies to the changes under review
- The changed AL object names and types โ especially permission sets, codeunits handling authentication or authorization, objects touching `Isolated Storage`, `OAuth2` flows, web service endpoints, API pages, event publishers, and RecordRef helpers.
- The changed procedures and triggers, weighted toward those that call `HttpClient`, validate or compose URLs, write to telemetry, read or write secrets, unwrap SecretText, manipulate record-level security, expose var Boolean guard parameters, or bypass the permission model (for example, `RecordRef.Open`, `Record.WritePermission`, direct table access from a non-owning app).
-- Tokens extracted from the diff that relate to security concerns (`IsolatedStorage`, `SetEncrypted`, `OAuth2`, `SecretText`, `Unwrap`, `NonDebuggable`, `Password`, `Token`, `HttpClient`, `Uri`, `AreURIsHaveSameHost`, `IsValidURIPattern`, `RecordRef`, `RecordId`, `Open`, `IntegrationEvent`, `SkipValidation`, `HasAccess`, `Permission`, `UserSecurityId`, `Commit`).
+- Tokens extracted from the diff that relate to security concerns (`IsolatedStorage`, `SetEncrypted`, `OAuth2`, `SecretText`, `Unwrap`, `NonDebuggable`, `Password`, `Token`, `HttpClient`, `Uri`, `AreURIsHaveSameHost`, `IsValidURIPattern`, `RecordRef`, `RecordId`, `TransferFields`, `Codeunit.Run`, `Access = Internal`, `internalsVisibleTo`, `Open`, `IntegrationEvent`, `SkipValidation`, `HasAccess`, `Permission`, `UserSecurityId`, `Commit`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone.
+Always worklist `internal-access-is-not-a-security-boundary.md` when changed comments or code rely on `Access = Internal` or `internalsVisibleTo` to protect a sensitive operation, or an internal `OnRun` codeunit performs privileged work without an independent authorization boundary. Do not flag `internal` used only to keep implementation details out of the supported API.
+
+For secret values, select the most specific sink owner:
+
+- When a `Text`/`Code` credential is declared, passed, returned, or unwrapped without a visible HTTP URI/header/body sink, use `secrettext-for-credentials.md`.
+- When that value is interpolated into a URI, authorization header, or HTTP body and sent through `HttpClient`, use `secrettext-with-httpclient.md` as the primary finding. It supersedes the generic credential-type article at that location; keep the latter only as a supporting reference when useful.
+
Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.
When the post-conflict worklist is empty because no applicable security knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable security knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array.
@@ -53,7 +60,7 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice`
- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the anti-pattern violates a platform-level guarantee (for example, documented secret-handling rules, permission-model invariants, or data-protection requirements). When the file does not make such a claim, the ceiling is `major`.
- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape.
-- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. Repository-wide observations MAY omit `location`.
+- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present.
Set `confidence` to:
@@ -63,7 +70,7 @@ Set `confidence` to:
After evaluating each worklist entry, also consider whether the diff exhibits a security defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain โ emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material security defect a knowledgeable BC reviewer would agree is wrong โ steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly security; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate โ if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
-For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
@@ -77,7 +84,7 @@ Outcome selection:
## Output
-Output conforms to the DO output contract. A populated example:
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Security"`. A populated example:
```json
{
@@ -89,7 +96,7 @@ Output conforms to the DO output contract. A populated example:
},
"findings": [
{
- "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md",
+ "id": "microsoft/knowledge/security/secrettext-for-credentials.md",
"severity": "blocker",
"message": "A bearer token is declared as a Text parameter and passed through the HTTP request path as plain text. The referenced guidance requires credentials to flow as SecretText end-to-end.",
"location": {
@@ -98,29 +105,31 @@ Output conforms to the DO output contract. A populated example:
"range": { "start-line": 85, "end-line": 89 }
},
"references": [
- { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" }
+ { "path": "microsoft/knowledge/security/secrettext-for-credentials.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Security"
},
{
- "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md",
+ "id": "microsoft/knowledge/security/secrets-isolated-storage.md",
"severity": "minor",
- "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.",
+ "message": "A setup table stores an API key in an ordinary Text field, exposing it through table reads and exports. Persist it in IsolatedStorage instead.",
"location": {
- "file": "src/Integration/ApiClient.Codeunit.al",
- "line": 201
+ "file": "src/Integration/ExternalServiceSetup.Table.al",
+ "line": 12
},
"references": [
- { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" }
+ { "path": "microsoft/knowledge/security/secrets-isolated-storage.md" }
],
- "confidence": "medium"
+ "confidence": "medium",
+ "domain": "Security"
}
],
"suppressed": []
}
```
-The empty-corpus case โ BCQuality's state until security knowledge files land โ produces:
+When no applicable security knowledge is available, the report is:
```json
{
@@ -134,4 +143,3 @@ The empty-corpus case โ BCQuality's state until security knowledge files land
"suppressed": []
}
```
-
diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md
index d926df3..2703c87 100644
--- a/microsoft/skills/review/al-style-review.md
+++ b/microsoft/skills/review/al-style-review.md
@@ -4,7 +4,7 @@ id: al-style-review
version: 1
title: AL style review
description: Reviews AL source changes against naming, labelling, and code-convention guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -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, 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,11 +40,16 @@ Discard files that are not applicable. Retain conditionally applicable files onl
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
- Changed AL objects โ especially API pages (`PageType = API`), tables and pages declaring Labels/TextConsts, codeunits issuing `Error`/`Message`/`Confirm`, and any file whose name violates the `..al` convention.
-- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, option fields, error-handling call sites, and codeunit-internal method calls.
-- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `OptionMembers`, `OptionCaption`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `this.`, `StrSubstNo`).
+- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, error-handling call sites, and API declarations.
+- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `StrSubstNo`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object or declaration. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone.
+Do not worklist `temporary-variable-temp-prefix.md` for an event publisher parameter. `events/prefix-temporary-record-event-parameters-with-temp.md` is the exclusive owner of that shape.
+
+Apply these high-signal mappings before fuzzy topic ranking:
+
+- A `Label` or `TextConst` contains multiple or ambiguous placeholders but has no `Comment`, or its Comment does not explain every placeholder โ `label-comment-explains-placeholders.md`. A single placeholder whose meaning is explicit in the text, such as `Customer %1`, is allowed without a Comment and must not be flagged.
Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions.
When the post-conflict worklist is empty because no applicable style knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable style knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array.
@@ -53,15 +58,17 @@ 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 โ 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:
- `high` when the detection is based on an unambiguous pattern match.
- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`.
- `low` when the finding is an advisory derived only from applicability.
-After evaluating each worklist entry, also consider whether the diff exhibits a style defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain โ emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a clear, widely-accepted AL style violation with a concrete basis a knowledgeable BC reviewer would agree on โ steelman it first and drop personal preference, speculation, and any single defensible formatting choice among several; when in doubt, omit. The scope is strictly style; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate โ if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
+After evaluating each worklist entry, also consider whether the diff exhibits a style defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain โ emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a clear, widely-accepted AL style violation with a concrete basis a knowledgeable BC reviewer would agree on โ steelman it first and drop personal preference, speculation, and any single defensible formatting choice among several; when in doubt, omit. The scope is strictly style โ naming, labelling, formatting, and analyzer-adjacent conventions. A correctness, logic, data-integrity, or contract defect is NOT a style finding even when it can be reworded as a convention: a method that mutates a shared `Record`'s filters, an unfiltered `DeleteAll`, a violated interface contract, or a wrong boolean guard are behavioural defects, not conventions โ do not emit them here under a style framing. If a specific domain leaf covers the concern (performance, security, error-handling, โฆ) it belongs there; if no knowledge file in any domain covers it, it belongs to the `al-code-review` super-skill's cross-cutting self-review agent channel (`from-sub-skill: "agent"`, `severity` capped at `minor`), not to this leaf. A reliable test: if you cannot cite a style `## Best Practice`/`## Anti Pattern` for the concern, it is very likely not a style finding. Before emitting, check the worklist for a knowledge file that matches the candidate โ if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
-For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+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.
@@ -75,7 +82,7 @@ Outcome selection:
## Output
-Output conforms to the DO output contract. A populated example:
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Style"`. A populated example:
```json
{
@@ -87,20 +94,20 @@ Output conforms to the DO output contract. A populated example:
},
"findings": [
{
- "id": "microsoft/knowledge/style/apply-approved-label-suffixes.md",
+ "id": "microsoft/knowledge/style/label-comment-explains-placeholders.md",
"severity": "minor",
- "message": "A Label named Text000 has no approved suffix (Msg/Err/Qst/Tok/Lbl/Txt). Per the referenced CodeCop AA0074 guidance, every Label and TextConst carries a suffix indicating its consuming call.",
+ "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/apply-approved-label-suffixes.md" }
+ { "path": "microsoft/knowledge/style/label-comment-explains-placeholders.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Style"
}
],
"suppressed": []
}
```
-
diff --git a/microsoft/skills/review/al-telemetry-review.md b/microsoft/skills/review/al-telemetry-review.md
new file mode 100644
index 0000000..1c2046d
--- /dev/null
+++ b/microsoft/skills/review/al-telemetry-review.md
@@ -0,0 +1,104 @@
+---
+kind: action-skill
+id: al-telemetry-review
+version: 1
+title: AL telemetry review
+description: Performs an AL telemetry review against guidance from BCQuality.
+inputs: [pr-diff, file-path, folder-path]
+outputs: [findings-report]
+bc-version: [all]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# AL telemetry review
+
+Reviews AL source changes against the `telemetry` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
+
+An orchestrator invokes this skill with 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
+
+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
+
+Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context:
+
+- `bc-version` โ the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`.
+- `technologies` โ `[al]`.
+- `countries` โ the countries declared in the consuming app's `app.json`. Default to the orchestrator's configured context; if absent, `unknown`.
+- `application-area` โ the union of application areas declared by the changed objects. Pass the actual set; do not substitute `[all]`. If the area cannot be determined from the changes, the dimension is `unknown`.
+
+Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the finding's `message` MUST name the dimension or dimensions that were unknown.
+
+## Worklist
+
+Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
+
+- The changed AL objects and procedures โ especially telemetry wrapper codeunits, feature lifecycle instrumentation, error logging, integration diagnostics, and background/session processing.
+- Calls to `Session.LogMessage`, `Session.LogError`, or `FeatureTelemetry` methods, weighted toward the event ID, verbosity, data classification, custom dimensions, and `TelemetryScope` arguments.
+- Telemetry infrastructure codeunits that implement `"Telemetry Logger"` or subscribe to `"Telemetry Loggers".OnRegisterTelemetryLogger`.
+- Tokens extracted from the diff that relate to telemetry (`Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, `TelemetryScope`, `ExtensionPublisher`, `All`, `Verbosity`, `Critical`, `Error`, `Warning`, `Normal`, `Verbose`, `DataClassification`, `CustomDimensions`, `Application Insights`, `Telemetry Logger`, `Telemetry Loggers`, `OnRegisterTelemetryLogger`, `LogUsage`, `LogError`, `LogUptake`, `Feature Uptake Status`, `Discovered`, `Set up`, `Used`, `Undiscovered`).
+
+A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone. When the diff contains no telemetry-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.
+
+The following targeted checks cover every current `telemetry` article. Treat each as a candidate-selection cue:
+
+- A `Session.LogMessage` event ID is empty, generated dynamically, reused for different events, changed on an existing event, or uses a placeholder such as `0000`, `1234`, `TODO`, or `XX0000` โ `telemetry-event-id-stable-unique`.
+- `TelemetryScope::All` is used for a clearly publisher-only implementation diagnostic, or `ExtensionPublisher` hides a clearly customer-actionable failure from environment telemetry โ `choose-telemetry-scope-by-audience`. Do not infer the audience when the message and surrounding branch are ambiguous.
+- An explicit failure branch logs through `Session.LogMessage` with `Verbosity::Normal` or `Verbose`, or a non-error event is inflated to `Error`/`Critical` โ `match-verbosity-to-signal-severity`.
+- A new feature's visible uptake calls skip `Discovered` or `Set up`, jump directly to `Used`, or use inconsistent feature-name literals across states โ `feature-uptake-transitions-in-order`. Require repository-level lifecycle evidence; one isolated call is not proof.
+- `FeatureTelemetry.LogUsage` runs before success is known or on a failure path โ `feature-usage-only-after-success`. `LogUptake(...Used)` records an attempt and is not this anti-pattern.
+- A complete app or app family uses `FeatureTelemetry` without any registered `"Telemetry Logger"`, or registers more than one implementation for the same publisher โ `register-one-telemetry-logger-per-publisher`. Absence requires repository/app-family context.
+- A custom-dimension key contains spaces or non-PascalCase naming, or an existing event ID changes/removes a shipped key โ `keep-custom-dimension-schema-stable`. Treat naming alone as advisory; the schema change is the compatibility defect.
+
+Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.
+
+When the post-conflict worklist is empty because no applicable telemetry knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable telemetry knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array.
+
+## Action
+
+For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows:
+
+- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the anti-pattern violates a platform-level guarantee; otherwise the ceiling is `major`.
+- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape.
+- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present.
+
+Set `confidence` to:
+
+- `high` when the detection is based on an unambiguous API and `TelemetryScope` argument.
+- `medium` when determining whether a signal is customer-actionable requires heuristic interpretation or when any frontmatter dimension was `unknown`.
+- `low` when the finding is an advisory derived only from applicability.
+
+After evaluating each worklist entry, also consider whether the diff exhibits a telemetry defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain โ emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material telemetry defect a knowledgeable BC reviewer would agree is wrong โ steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly telemetry; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate โ if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
+
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: replace `TelemetryScope::All` with `TelemetryScope::ExtensionPublisher` for a clearly publisher-only diagnostic). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+
+Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
+
+Outcome selection:
+
+- `completed` โ the skill evaluated every worklist item.
+- `no-knowledge` โ no applicable telemetry knowledge survived filtering.
+- `not-applicable` โ the diff touches no telemetry emission, wrapper, or feature-instrumentation surface.
+- `partial` โ a budget was hit before the worklist was exhausted.
+- `failed` โ an unrecoverable error occurred.
+
+## Output
+
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Telemetry"`. The empty-corpus case produces:
+
+```json
+{
+ "skill": { "id": "al-telemetry-review", "version": 1 },
+ "outcome": "no-knowledge",
+ "summary": {
+ "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 },
+ "coverage": { "worklist-size": 0, "items-evaluated": 0 }
+ },
+ "findings": [],
+ "suppressed": []
+}
+```
diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md
new file mode 100644
index 0000000..c96ac83
--- /dev/null
+++ b/microsoft/skills/review/al-testing-review.md
@@ -0,0 +1,135 @@
+---
+kind: action-skill
+id: al-testing-review
+version: 1
+title: AL testing review
+description: Performs an AL testing review against guidance from BCQuality.
+inputs: [pr-diff, file-path, folder-path]
+outputs: [findings-report]
+bc-version: [all]
+technologies: [al]
+countries: [w1]
+application-area: [all]
+---
+
+# AL testing review
+
+Reviews AL source changes against the `testing` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
+
+An orchestrator invokes this skill with 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
+
+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
+
+Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context:
+
+- `bc-version` โ the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`.
+- `technologies` โ `[al]`.
+- `countries` โ the countries declared in the consuming app's `app.json`. Default to the orchestrator's configured context; if absent, `unknown`.
+- `application-area` โ the union of application areas declared by the changed objects. Pass the actual set; do not substitute `[all]`. If the area cannot be determined from the changes, the dimension is `unknown`.
+
+Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the finding's `message` MUST name the dimension or dimensions that were unknown.
+
+## Worklist
+
+Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
+
+- The changed AL object names and types โ especially codeunits with `Subtype = Test`, test runner codeunits with `TestIsolation`, test libraries, and codeunits that define UI handlers.
+- The changed methods and attributes, weighted toward `[Test]`, `[TransactionModel(...)]`, `[TestPermissions(...)]`, `[HandlerFunctions(...)]`, handler attributes, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, fixture initialization, and test-library calls.
+- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `Subtype = TestRunner`, `TestIsolation`, `TestPermissions`, `Restrictive`, `NonRestrictive`, `Disabled`, `Permissions Mock`, `Library - Lower Permissions`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `SendNotificationHandler`, `RecallNotificationHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Init`, `Insert`).
+
+A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone. When the diff contains no testing-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.
+
+The following targeted checks cover every current `testing` article. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action.
+
+- A method in a `Subtype = Test` codeunit adds or changes `[TransactionModel(...)]`, exercises code that calls `Commit` under `AutoRollback`, defaults broadly to `AutoCommit`, or chooses `None` for a writing test โ `transactionmodel-attribute-governs-test-transactions`.
+- An `AutoCommit` test runs under a `Subtype = TestRunner` codeunit that omits `TestIsolation` or sets it to `Disabled`, leaving committed data between tests โ `testisolation-belongs-on-the-test-runner`. Require runner/repository context; a standalone test file cannot prove which runner executes it.
+- A permission-sensitive test uses `TestPermissions = Disabled`, claims to test a restricted user without `"Permissions Mock"`/`"Library - Lower Permissions"`, or declares `[TestPermissions(...)]` without applying that context โ `permission-tests-must-lower-the-execution-context`.
+- Test fixture code manually calls `Init`/`Insert`, invents keys or prerequisite records, or bypasses available `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, or equivalent library codeunits โ `use-library-codeunits-for-test-fixtures`.
+- `asserterror` is added or changed without a following `Assert.ExpectedError`, `Assert.ExpectedErrorCode`, or a purpose-built assertion such as `ExpectedTestFieldError` โ `asserterror-needs-expectederror-and-code`.
+- A test path raises UI and `[HandlerFunctions(...)]` does not match the invoked handlers, or the test has no meaningful evidence of the UI result (for example, it treats a Boolean set before the action as proof of success) โ `ui-handlers-in-tests`. A capture/reset/assert-after-`RunModal` pattern is valid. Enqueue/dequeue and `AssertEmpty` are required only when order, count, text, replies, or a scripted sequence is part of the contract. Only nonoptional handlers have to execute: a listed handler declared `[SendNotificationHandler(true)]` or `[RecallNotificationHandler(true)]` is optional by design, so do not treat it as unmatched when the run never raises the notification.
+
+Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.
+
+When the post-conflict worklist is empty because no applicable testing knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable testing knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array.
+
+## Action
+
+For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows:
+
+- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the test can pass while verifying the wrong behavior or can leave committed data that contaminates later tests; otherwise the ceiling is `major`.
+- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape.
+- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present.
+
+For `ui-handlers-in-tests`, use `major` when missing or incorrectly listed handlers make the test fail at runtime. Use `minor` when the test executes but lacks a meaningful semantic postcondition, including a pre-set Boolean used as proof. Do not escalate solely because a handler does not use queue storage or asserts inside the handler.
+
+Set `confidence` to:
+
+- `high` when the detection is based on an unambiguous pattern match (attribute, handler declaration, assertion sequence, or fixture call).
+- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`.
+- `low` when the finding is an advisory derived only from applicability.
+
+After evaluating each worklist entry, also consider whether the diff exhibits a testing defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain โ emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material testing defect a knowledgeable BC reviewer would agree is wrong โ steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly AL testing; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate โ if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
+
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add the matching `ExpectedError` assertion after `asserterror`; add or remove a handler name in `HandlerFunctions`, except that a listed optional notification handler must never be proposed for removal; add `LibraryVariableStorage.Clear` or `AssertEmpty` when queue/LVS intentionally verifies interaction order, count, text, replies, or a scripted sequence; or replace hand-rolled fixture creation with an evident library call). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+
+Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
+
+Outcome selection:
+
+- `completed` โ the skill evaluated every worklist item.
+- `no-knowledge` โ no applicable testing knowledge survived filtering.
+- `not-applicable` โ the diff touches no test codeunit, runner, method, handler, assertion, or fixture surface.
+- `partial` โ a budget was hit before the worklist was exhausted.
+- `failed` โ an unrecoverable error occurred.
+
+## Output
+
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Testing"`. A populated example:
+
+```json
+{
+ "skill": { "id": "al-testing-review", "version": 1 },
+ "outcome": "completed",
+ "summary": {
+ "counts": { "blocker": 0, "major": 1, "minor": 0, "info": 0 },
+ "coverage": { "worklist-size": 1, "items-evaluated": 1 }
+ },
+ "findings": [
+ {
+ "id": "microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md",
+ "severity": "major",
+ "message": "The negative test uses asserterror without checking the resulting message or error code, so any unrelated setup or permission error can make the test pass.",
+ "location": {
+ "file": "test/SalesPostingTests.Codeunit.al",
+ "line": 42
+ },
+ "references": [
+ { "path": "microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md" }
+ ],
+ "confidence": "high",
+ "domain": "Testing",
+ "suggested-code": "asserterror PostInvalidOrder();\nAssert.ExpectedError(ExpectedPostingErr);"
+ }
+ ],
+ "suppressed": []
+}
+```
+
+The empty-corpus case produces:
+
+```json
+{
+ "skill": { "id": "al-testing-review", "version": 1 },
+ "outcome": "no-knowledge",
+ "summary": {
+ "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 },
+ "coverage": { "worklist-size": 0, "items-evaluated": 0 }
+ },
+ "findings": [],
+ "suppressed": []
+}
+```
diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md
index ef2e94d..8ffd731 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]
@@ -16,13 +16,13 @@ application-area: [all]
Reviews AL page source and control add-in UI files against the `ui` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-UI findings apply to page files โ files that declare `PageType = ...`, including `*.Page.al` under the standard file-naming convention โ and to JavaScript/CSS/HTML files that render Business Central control add-ins. The skill returns `not-applicable` when the diff contains no page or control add-in UI changes.
+UI findings apply to page files โ files that declare `PageType = ...`, including `*.Page.al` under the standard file-naming convention โ and to JavaScript/CSS/HTML files that implement Business Central control add-ins, including their client-service communication. The skill returns `not-applicable` when the diff contains no page or control add-in changes.
-An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces a single JSON document conforming to the DO output contract.
+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
@@ -39,9 +39,9 @@ Discard files that are not applicable. Retain conditionally applicable files onl
Narrow the relevant files to the subset that applies to the changes under review.
-- **UI-file filter.** UI review applies to files declaring `page`, `pageextension`, or `pagecustomization`, and to control add-in JavaScript/CSS/HTML that changes rendered UI. When the diff contains no such files, return `outcome: "not-applicable"` without evaluating knowledge files.
-- For each relevant knowledge file, compute overlap against changed page declarations and control add-in UI files, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, `ShowCaption`, `InstructionalText`, `GridLayout`, `Style`, `StyleExpr`, action definitions, field-level properties, DOM creation, ARIA attributes, and keyboard/focus handlers.
-- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions).
+- **UI-file filter.** UI review applies to files declaring `page`, `pageextension`, or `pagecustomization`, and to JavaScript/CSS/HTML that implements a control add-in's rendering or Business Central communication. When the diff contains no such files, return `outcome: "not-applicable"` without evaluating knowledge files.
+- For each relevant knowledge file, compute overlap against changed page declarations and control add-in files, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, `ShowCaption`, `InstructionalText`, `GridLayout`, `Style`, `StyleExpr`, promoted action definitions, field importance, page background tasks, DOM creation, ARIA attributes, keyboard/focus handlers, packaged-resource AJAX, and calls from JavaScript into AL.
+- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Importance`, `Promoted`, `Additional`, `area(Promoted)`, `actionref`, `PromotedCategory`, `PromotedOnly`, `PromotedIsBig`, `ShowAs`, `SplitButton`, `EnqueueBackgroundTask`, `OnAfterGetCurrRecord`, `OnAfterGetRecord`, `OnPageBackgroundTaskCompleted`, `OnPageBackgroundTaskError`, `RunPageBackgroundTask`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `control-add-in`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `packaged-resource`, `ajax`, `$.get`, `$.ajax`, `XMLHttpRequest`, `xhrFields`, `withCredentials`, `withcredentials`, `InvokeExtensibilityMethod`, `invokeextensibilitymethod`, `skipIfBusy`, `successCallback`, `success-callback`, `errorCallback`, `setInterval`, `JSON.stringify`, `payload`, `throttling`, `reduced-functionality`, `ClientServicesMaxUploadSize`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed page element. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone.
@@ -53,15 +53,17 @@ When the post-conflict worklist is empty because no applicable UI knowledge exis
For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. UI text findings are generally `minor` โ they affect localization and polish rather than correctness. Accessibility findings for missing labels, broken grid semantics, semantic color without text meaning, or UI-rendering control add-in changes can be `major`; use `minor` for low-risk manual-review reminders and polish issues.
+For packaged-resource requests, flag `$.get` or AJAX/XHR that omits `withCredentials` only when the URL is identifiable as a resource in the control add-in package; do not generalize the rule to external endpoints. For `InvokeExtensibilityMethod`, flag repeated or timer-driven calls that can overlap because they do not wait for the success/error callbacks, and unbounded serialized payloads sent in one call. Prefer bounded chunks serialized through completion callbacks. Do not emit generic browser or JavaScript performance advice.
+
Set `confidence` to:
- `high` when the detection is based on an unambiguous pattern match (banned term literal, missing "Specifies" opener on a field tooltip, caption exceeding documented limit).
- `medium` when detection relies on heuristics (judging whether a caption is a noun phrase or a sentence phrase) or when any frontmatter dimension was `unknown`.
- `low` when the finding is an advisory derived only from applicability.
-After evaluating each worklist entry, also consider whether the diff exhibits a UI defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain โ emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material UI defect a knowledgeable BC reviewer would agree is wrong โ steelman it first and drop anything speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly UI; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate โ if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
+This leaf emits only knowledge-backed UI and accessibility findings. Do NOT emit reference-less `agent:` findings in this domain: online evaluation shows the UI/accessibility agent-finding channel yields almost no accepted findings and a high volume of dismissed noise, so a UI or accessibility concern that no worklist knowledge file covers is omitted here rather than emitted with `references: []`. When you spot a material UI or accessibility defect no article covers, the durable fix is to add a knowledge article in BCQuality (per the online-eval self-improvement loop) so this leaf can cite it โ not a one-off reference-less finding. Before treating a candidate as uncovered, check the worklist for a knowledge file that matches it; if one exists, emit it as a knowledge-backed finding. See `skills/do.md` for the full contract.
-For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
@@ -69,13 +71,13 @@ Outcome selection:
- `completed` โ the skill evaluated every worklist item.
- `no-knowledge` โ no applicable UI knowledge survived filtering.
-- `not-applicable` โ the diff contains no page, pageextension, pagecustomization, or control add-in UI files.
+- `not-applicable` โ the diff contains no page, pageextension, pagecustomization, or control add-in implementation files.
- `partial` โ a budget was hit before the worklist was exhausted.
- `failed` โ an unrecoverable error occurred.
## Output
-Output conforms to the DO output contract. A populated example:
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Accessibility"`. A populated example:
```json
{
@@ -87,20 +89,20 @@ Output conforms to the DO output contract. A populated example:
},
"findings": [
{
- "id": "microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md",
+ "id": "microsoft/knowledge/ui/show-caption-on-editable-fields.md",
"severity": "minor",
- "message": "Field ToolTip is a fragment ('Customer name') โ missing the 'Specifies' opener and the terminating period the house-style guidance requires.",
+ "message": "An editable page field sets ShowCaption = false, removing the visible and accessible label. Leave ShowCaption enabled or use a documented exception pattern.",
"location": {
"file": "src/Sales/CustomerCard.Page.al",
"line": 58
},
"references": [
- { "path": "microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md" }
+ { "path": "microsoft/knowledge/ui/show-caption-on-editable-fields.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Accessibility"
}
],
"suppressed": []
}
```
-
diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md
index 7ccfa4a..94851a3 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, 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
@@ -38,8 +38,11 @@ Discard files that are not applicable. Retain conditionally applicable files (an
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
- The changed AL object names and types โ especially codeunits with `Subtype = Upgrade` or `Subtype = Install`, tables and tableextensions adding or changing fields, enums and enumextensions, and objects under `Hybrid*`/`Migration`/`Upgrade` namespaces.
-- The changed triggers and procedures, weighted toward `OnUpgradePerCompany`, `OnUpgradePerDatabase`, `OnValidateUpgradePerCompany`, `OnValidateUpgradePerDatabase`, `OnInstallAppPerCompany`, and the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers.
-- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnValidateUpgrade`, `DataTransfer`, `CopyFields`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `DataVersion`, `ExecutionContext`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`).
+- The changed triggers and procedures, weighted toward `OnCheckPreconditionsPerCompany`/`PerDatabase`, `OnUpgradePerCompany`/`PerDatabase`, `OnValidateUpgradePerCompany`/`PerDatabase`, `OnInstallAppPerCompany`/`PerDatabase`, the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers, and helper procedures transitively reachable from those entry points.
+- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Subtype = Install`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnCheckPreconditions`, `OnUpgrade`, `OnValidateUpgrade`, `OnInstallApp`, `DataTransfer`, `CopyFields`, `Insert`, `Modify`, `Delete`, `Rename`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `DataVersion`, `ExecutionContext`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`).
+- For each `OnCheckPreconditions...` and `OnValidateUpgrade...` trigger, build the best available call graph from surrounding unchanged source as well as changed hunks, tracing resolved calls through reachable local or internal helpers. Worklist the check-only rule when a database write occurs either directly in the trigger or in any helper procedure reachable from it. Writes include `Insert`, `Modify`, `ModifyAll`, `Delete`, `DeleteAll`, `Rename`, and `DataTransfer`. Also perform the reverse check when a PR changes a writing helper body: worklist the rule when that helper is invoked directly or transitively by an unchanged check or validation trigger.
+- Treat a direct write or a fully resolved call chain as high-confidence evidence. When cross-object dispatch, unavailable declarations, or an incomplete call graph prevents proving the complete chain, cap confidence at `medium`, name the unresolved edge in the finding, and do not claim a violation without a resolved path from a check or validation trigger to a write.
+- Worklist the install-versus-upgrade rule when migration helpers are reachable only from an install codeunit.
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone. When the diff contains no upgrade-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.
@@ -53,17 +56,17 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice`
- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` for irreversible data corruption (enum-ordinal shift, unguarded reads that abort the upgrade) and for changes that would ship to customers without a migration path (new InitValue on an existing table without upgrade code).
- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape.
-- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file.
+- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present.
Set `confidence` to:
-- `high` when the detection is based on an unambiguous pattern match.
+- `high` when the detection is based on an unambiguous pattern match and any required helper reachability is fully established.
- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`.
- `low` when the finding is an advisory derived only from applicability.
After evaluating each worklist entry, also consider whether the diff exhibits a upgrade defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain โ emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material upgrade or breaking-change defect a knowledgeable BC reviewer would agree is wrong โ steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly upgrade; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate โ if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
-For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
@@ -77,7 +80,7 @@ Outcome selection:
## Output
-Output conforms to the DO output contract. A populated example:
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Upgrade"`. A populated example:
```json
{
@@ -89,7 +92,7 @@ Output conforms to the DO output contract. A populated example:
},
"findings": [
{
- "id": "microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md",
+ "id": "microsoft/knowledge/upgrade/enum-values-additive-at-end.md",
"severity": "blocker",
"message": "A new enum value was inserted at ordinal 1, shifting every subsequent value by one. Rows that store the old ordinal 1 will silently resolve to the new value. Per the referenced guidance, enum values must be appended at the end.",
"location": {
@@ -97,12 +100,12 @@ Output conforms to the DO output contract. A populated example:
"line": 7
},
"references": [
- { "path": "microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md" }
+ { "path": "microsoft/knowledge/upgrade/enum-values-additive-at-end.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Upgrade"
}
],
"suppressed": []
}
```
-
diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md
index 4109722..19d0735 100644
--- a/microsoft/skills/review/al-web-services-review.md
+++ b/microsoft/skills/review/al-web-services-review.md
@@ -3,11 +3,11 @@ kind: action-skill
id: al-web-services-review
version: 1
title: AL web services review
-description: Reviews AL source changes against web-services (API page) guidance from BCQuality.
-inputs: [pr-diff, file-path]
+description: Reviews AL API surfaces and webhook integration handlers against web-services guidance from BCQuality.
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
-technologies: [al]
+technologies: [al, javascript]
countries: [w1]
application-area: [all]
---
@@ -16,18 +16,18 @@ application-area: [all]
Reviews AL source changes against the `web-services` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-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
Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context:
- `bc-version` โ the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`.
-- `technologies` โ `[al]`.
+- `technologies` โ `[al]` or `[javascript]`.
- `countries` โ the countries declared in the consuming app's `app.json`. Default to the orchestrator's configured context; if absent, `unknown`.
- `application-area` โ the union of application areas declared by the changed objects. Pass the actual set; do not substitute `[all]`. If the area cannot be determined from the changes, the dimension is `unknown`.
@@ -37,9 +37,10 @@ Discard files that are not applicable. Retain conditionally applicable files (an
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
-- The changed AL object names and types โ especially page objects declared with `PageType = API`, and any procedure on such a page that exposes a bound action.
-- The changed properties and triggers, weighted toward API page metadata (`APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SourceTable`), CRUD guards (`InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`), the `OnOpenPage` trigger, and `OnValidate` triggers on exposed fields.
-- Tokens extracted from the diff that relate to API surface and behaviour (`PageType`, `API`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SystemId`, `ServiceEnabled`, `WebServiceActionContext`, `SetActionResponse`, `ReadIsolation`, `IsolationLevel`, `ReadCommitted`, `InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`, `SourceTable`).
+- The changed AL object names and types โ especially pages declared with `PageType = API`, API page `part` controls, queries declared with `QueryType = API`, and procedures that expose bound actions.
+- The changed properties and triggers, weighted toward API page metadata (`APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SourceTable`, `SourceTableTemporary`), navigation metadata (`SubPageLink`, `Multiplicity`, and visible singleton or collection semantics), CRUD guards (`InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`), the `OnOpenPage` trigger, and `OnValidate` triggers on exposed fields.
+- Webhook subscriber handlers and subscription lifecycle code, especially code that creates or renews subscriptions, handles `validationToken`, schedules from `expirationDateTime`, or targets resources whose eligibility is visible in the diff.
+- Tokens extracted from the diff that relate to API surface and behaviour (`PageType`, `QueryType`, `API`, `api-page`, `page-part`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SystemId`, `SubPageLink`, `subpagelink`, `Multiplicity`, `multiplicity`, `Many`, `ZeroOrOne`, `SourceTableTemporary`, `Job Queue Entry`, `webhook`, `webhookSupportedResources`, `webhook-supported-resources`, `subscriptions`, `notificationUrl`, `validationToken`, `validationtoken`, `expirationDateTime`, `expirationdatetime`, `ServiceEnabled`, `WebServiceActionContext`, `SetActionResponse`, `ReadIsolation`, `IsolationLevel`, `ReadCommitted`, `InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`, `SourceTable`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone.
@@ -53,7 +54,9 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice`
- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the anti-pattern violates a platform-level guarantee. When the file does not make such a claim, the ceiling is `major`.
- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape.
-- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. Repository-wide observations MAY omit `location`.
+- Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present.
+
+For API parts whose parent declares `ODataKeyFields = SystemId`, detect a child foreign key linked to a parent business field instead of `Field(SystemId)`. Do not apply the SystemId-link rule to APIs intentionally keyed by another field. Omitted `Multiplicity` is valid and means the documented default 1:N collection; never report omission alone. Report an explicit `ZeroOrOne` only when the visible contract clearly intends a collection or deep insert, and report an explicit `Many` only when it clearly intends a singleton. Singleton metadata requires an explicit `ZeroOrOne`; do not infer singleton intent from naming alone. For webhook eligibility, detect `QueryType = API`, `SourceTableTemporary = true`, composite `ODataKeyFields` (including an omitted property when a visible source primary key is composite), Job Queue Entry, and visible system-table sources; do not infer an unknown table number. For lifecycle code, require both create and renew paths to use a handler that returns the query-string `validationToken` verbatim with `200 OK`, and flag renewal scheduling that assumes subscriptions are permanent instead of using `expirationDateTime`. Do not emit generic HTTP or REST advice.
Set `confidence` to:
@@ -71,27 +74,27 @@ Outcome selection:
- `completed` โ the skill evaluated every worklist item; default when the skill finishes normally, including when the resulting `findings` array is empty.
- `no-knowledge` โ no applicable web-services knowledge survived Source, Relevance, configuration filtering, and conflict resolution. `findings` is empty.
-- `not-applicable` โ the task context lacks an AL dimension (no AL changes in the diff, or `technologies` filter rejected the task).
+- `not-applicable` โ the task context contains no AL API surface, JavaScript webhook subscription lifecycle code, or JavaScript notification handler, or the `technologies` filter rejected the task.
- `partial` โ a time or token budget was hit before the worklist was exhausted. `summary.coverage` reflects the evaluated subset; `outcome-reason` explains the cause.
- `failed` โ an unrecoverable error occurred. `outcome-reason` is required.
## Output
-Output conforms to the DO output contract. A populated example:
+Output conforms to the DO output contract. Every finding this skill emits MUST set `findings[].domain` to `"Web Services"`. A populated example:
```json
{
"skill": { "id": "al-web-services-review", "version": 1 },
"outcome": "completed",
"summary": {
- "counts": { "blocker": 0, "major": 1, "minor": 1, "info": 0 },
+ "counts": { "blocker": 0, "major": 0, "minor": 2, "info": 0 },
"coverage": { "worklist-size": 2, "items-evaluated": 2 }
},
"findings": [
{
"id": "microsoft/knowledge/web-services/set-required-api-page-properties.md",
- "severity": "major",
- "message": "This PageType = API page declares a SourceTable but omits APIPublisher and APIGroup, so the endpoint route cannot be composed and the entity is never published. Declare all six required API page properties.",
+ "severity": "minor",
+ "message": "This PageType = API page omits APIVersion, so it is exposed under beta by default rather than an explicit stable contract. Declare the intended version, such as APIVersion = 'v1.0'.",
"location": {
"file": "src/Api/CustomerApi.Page.al",
"line": 3,
@@ -100,7 +103,8 @@ Output conforms to the DO output contract. A populated example:
"references": [
{ "path": "microsoft/knowledge/web-services/set-required-api-page-properties.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Web Services"
},
{
"id": "microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md",
@@ -113,7 +117,8 @@ Output conforms to the DO output contract. A populated example:
"references": [
{ "path": "microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md" }
],
- "confidence": "high"
+ "confidence": "high",
+ "domain": "Web Services"
}
],
"suppressed": []
diff --git a/plugin.json b/plugin.json
new file mode 100644
index 0000000..c99785b
--- /dev/null
+++ b/plugin.json
@@ -0,0 +1,21 @@
+{
+ "name": "bcquality",
+ "description": "Quality skills and knowledge for Business Central development. Exposes a standalone AL review adapter backed by BCQuality's Entry protocol.",
+ "version": "0.2.0",
+ "author": {
+ "name": "microsoft/BCQuality",
+ "url": "https://github.com/microsoft/BCQuality"
+ },
+ "repository": "https://github.com/microsoft/BCQuality",
+ "license": "MIT",
+ "keywords": [
+ "bc",
+ "al",
+ "business-central",
+ "code-review",
+ "quality"
+ ],
+ "skills": [
+ "./skills/"
+ ]
+}
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 d0500a3..766131f 100644
--- a/skills/README.md
+++ b/skills/README.md
@@ -1,6 +1,9 @@
# BCQuality global skills
-This folder contains the skills that are not owned by any single layer. There are two kinds:
+This folder contains BCQuality's layer-independent protocol files and the
+host-native adapter used by standalone plugin installations.
+
+The protocol files have two kinds:
- **The entry-point skill** โ the first skill an agent invokes at runtime.
- **The three meta-skill contracts** โ stable references that define what the rest of BCQuality means.
@@ -23,6 +26,33 @@ Routing logic lives in Entry, not in the orchestrator. An agent that knows only
READ and DO are read on demand โ typically by the first action skill the agent executes after dispatch. They are not prerequisites for invoking Entry. WRITE is only used when scaffolding new content.
+## Standalone plugin adapter
+
+| Path | Role |
+|---|---|
+| [`al-code-review/SKILL.md`](al-code-review/SKILL.md) | Exposes BCQuality through the standard `SKILL.md` format when this repository is installed as a plugin. |
+
+The adapter is deliberately thin. It translates the caller's request into an
+Entry task context, then follows Entry's dispatch without owning routing,
+review, index, or output policy. It is not an action skill, is not considered
+by Entry, and should not accumulate behavior already defined by `entry.md`,
+`read.md`, `do.md`, or a layered action skill.
+
+This gives the two skill formats distinct roles:
+
+- `skills/al-code-review/SKILL.md` is the public host integration surface for a
+ standalone plugin installation.
+- `microsoft/skills/review/al-code-review.md` is BCQuality's internal
+ Microsoft-layer super-skill for coordinating a broad AL review.
+
+The host adapter and internal coordinator deliberately share the
+`al-code-review` name because they represent the same user-facing operation in
+their respective formats. Their locations distinguish their roles. The
+reference from the adapter to Entry, and from a dispatched super-skill to its
+leaf skills, is intentional progressive disclosure. It avoids registering
+every internal BCQuality protocol file as an ambient host skill while allowing
+each review domain to run in an isolated context.
+
These contracts are stable. Changes require a PR approved by both maintainers.
-For the end-to-end flow โ from orchestrator trigger through to findings integration โ see [`../agent-consumption.md`](../agent-consumption.md). For the high-level project framing, see [`../README.md`](../README.md).
+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
new file mode 100644
index 0000000..df54914
--- /dev/null
+++ b/skills/al-code-review/SKILL.md
@@ -0,0 +1,71 @@
+---
+name: al-code-review
+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
+
+This is BCQuality's host-native adapter for standalone plugin installations. It
+is not a BCQuality action skill and contains no review or routing policy. Its
+only responsibility is to translate the caller's request into an Entry task
+context and execute the resulting dispatch.
+
+## Execute
+
+1. Resolve `PLUGIN_ROOT` to the directory containing this plugin's root
+ `plugin.json`. This file is
+ `PLUGIN_ROOT/skills/al-code-review/SKILL.md`; when the host does not expose
+ the plugin root, resolve it two levels above this file.
+2. Build the `task-context` required by
+ `PLUGIN_ROOT/skills/entry.md`:
+ - Copy the caller's actual request verbatim into `goal`; do not replace a
+ focused request such as "review performance" with a generic full-review
+ goal.
+ - Set `inputs-available` to the inputs actually available to the review:
+ `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.
+ - If `BCQUALITY_ENABLED_LAYERS` is set, split its comma-separated value and
+ pass the trimmed, non-empty entries as `enabled-layers`; otherwise omit the
+ field and let Entry apply its default.
+ - If `BCQUALITY_DISABLED_SKILLS` is set, split its comma-separated value and
+ pass the trimmed, non-empty entries as `disabled-skills`; otherwise omit
+ the field.
+3. Read and execute `PLUGIN_ROOT/skills/entry.md` exactly as written, including
+ its Preparation step. Entry is authoritative for index freshness, routing,
+ defaults, and failure behavior; this adapter must not duplicate or weaken
+ those rules. Entry is written for a checkout whose root is the current
+ directory, so resolve every repo-relative path it names against
+ `PLUGIN_ROOT` rather than the caller's working directory, which is the
+ user's own project. In particular, run Preparation's index build as
+ `pwsh PLUGIN_ROOT/tools/Build-KnowledgeIndex.ps1`: the generator resolves
+ its own root and writes `PLUGIN_ROOT/knowledge-index.json`, which is not
+ shipped and is therefore absent on a fresh install. If `pwsh` is
+ unavailable or the build fails, continue โ READ falls back to path-based
+ discovery โ but do not treat a failed build as a failed review.
+4. Follow Entry's **How the agent uses the dispatch** instructions. Invoke only
+ the returned action skills, pass each dispatch entry's exact input subset,
+ and read `PLUGIN_ROOT/skills/read.md` and `PLUGIN_ROOT/skills/do.md` on
+ demand. When a dispatched super-skill requests isolated leaf execution and
+ the host supports child contexts, use them.
+5. Return each dispatched action skill's findings report unchanged. If Entry
+ returns `no-match` or `failed`, return its dispatch record unchanged.
+
+The internal `microsoft/skills/review/al-code-review.md` action skill remains
+the canonical coordinator for a broad AL review. Entry decides whether that
+super-skill or a narrower domain skill applies; this host adapter never chooses
+between them.
+
+## Layer selection is not a deny mechanism
+
+A plugin install ships the whole BCQuality tree, so `enabled-layers` here can
+only narrow *discovery*: the files of a layer left out of the list still exist
+on disk. This differs from the clone model Entry's Preparation step describes,
+where a consumer prunes its checkout to policy before the agent runs and the
+index is rebuilt over the pruned tree. Treat `BCQUALITY_ENABLED_LAYERS` as a
+selection filter, never as a security boundary. A host that needs a genuine
+deny mechanism must prune the installed tree itself.
diff --git a/skills/do.md b/skills/do.md
index 777f89f..e67faf6 100644
--- a/skills/do.md
+++ b/skills/do.md
@@ -19,7 +19,25 @@ 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
+
+An action skill is a *finder and applier*: its prose says how to discover candidate knowledge (Source), filter it (Relevance), narrow it to the task (Worklist), and shape output (Action). Every Business-Central-specific behavioural claim a skill acts on โ what a property defaults to, what a trigger does, why a given shape is or is not a defect โ belongs in a knowledge file the skill cites, not in the skill prose.
+
+This includes **negative knowledge**. A false-positive guard โ "pattern X is not a defect, because BC does Y" โ is as much a knowledge file as a positive best practice. When an eval shows the agent over-reporting a pattern, the fix is a knowledge file documenting why the pattern is legitimate, so the skill can cite it and any leaf can reuse it โ not a hard-coded exclusion buried in one skill. See `skills/write.md` (*Is this a knowledge file?*).
+
+Two rules follow for skill authors:
+
+- **Do not add a BC fact to a skill.** If you are editing a skill to change *what it flags* โ adding an exclusion, encoding a platform default, teaching it that some pattern is fine โ you are holding a knowledge file, not a skill edit. Author the knowledge file and let Worklist route to it.
+- **Do not restate an article's fact inline.** A Worklist cue may name the article to load and the diff shape that selects it; it must not re-assert the article's reasoning, which then drifts from the source. Cite, don't copy.
+
+The meta-skills themselves (`read.md`, `do.md`, `write.md`) are domain-agnostic templates and carry no BC-specific rule.
## Frontmatter schema
@@ -43,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.
@@ -71,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 },
@@ -95,6 +136,7 @@ Every action skill emits a single JSON document that conforms to this schema:
],
"confidence": "high | medium | low",
"from-sub-skill": "string",
+ "domain": "string",
"suggested-code": "string",
"suggested-code-omission-reason": "string"
}
@@ -123,6 +165,71 @@ 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`.
+
+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) โ
@@ -170,6 +277,8 @@ Consumers that render output MAY treat agent findings differently from knowledge
**`findings[].message`** โ human-readable explanation of the finding. Single short paragraph. No markdown formatting assumptions.
+**Applicability is not a finding.** Loading an article into the worklist only means its rule must be evaluated. If the changed code does not violate the article's normative guidance, emit nothing for that article. An `info` finding still requires a concrete observation defined by the article; skills MUST NOT use `info` to list guidance that merely happened to be relevant.
+
**`findings[].location`** โ optional. When present:
- `file` MUST be a repo-relative path using forward slashes.
@@ -185,13 +294,26 @@ Findings without a `location` are permitted (for example, repository-wide observ
The first reference is the **primary** reference: the knowledge file the finding most directly cites. Additional references provide supporting context and are not ranked. `references` MAY be empty only for **agent findings** (see the `findings[].id` section above for the full encoding); any other finding MUST have at least one reference.
+**Reference-integrity gate (mandatory).** A knowledge-backed finding may cite only a path copied verbatim from the current knowledge index or from a file discovered by the index fallback, and the skill must have opened that exact file in full before citing it. Never construct a plausible slug or infer a path from a topic name. Immediately before emitting the JSON document:
+
+1. Verify every non-empty `references[].path` exists in the live checkout and was opened during this skill run.
+2. Verify every citation-based `findings[].id` exactly equals `references[0].path`.
+3. Remove any candidate that cannot satisfy both checks; it is not a knowledge-backed finding. Do not convert it into an agent finding merely to preserve it.
+4. If reference integrity cannot be checked reliably, return `outcome: "failed"` rather than emitting fabricated or unverified citations.
+
+This gate applies independently to every leaf result and again to a super-skill's rolled-up result.
+
**`findings[].confidence`** โ the skill's confidence that the finding is a true positive, given the evidence it evaluated. Not applicability confidence, not severity confidence. Values: `high`, `medium`, `low`.
**`findings[].from-sub-skill`** โ optional. Set only by super-skills. The `skill.id` of the sub-skill that produced the finding, or the literal string `"agent"` for an agent finding the super-skill produced from its own cross-cutting reasoning. Absent on findings emitted directly by a leaf skill โ including agent findings the leaf emits within its own domain, which appear in the leaf's own report without this field.
+**`findings[].domain`** โ optional in the shared schema for backward compatibility and for non-review findings. It is a short, human-readable display label for the review domain that produced the finding (for example, `Security`, `Breaking Changes`, `API & Web Services`). A review leaf skill MUST set it on every finding it emits. The value MUST be a non-empty, single-line string with no leading or trailing whitespace or control characters. Internal whitespace, punctuation, case, and non-ASCII characters are valid and significant.
+
+A review super-skill MUST preserve `domain` verbatim when rolling a leaf finding into its top-level `findings[]`, including preserving its absence from older producers, and MUST set it to `"Agent"` for agent findings it emits about cross-cutting concerns. Consumers MUST tolerate its absence. When rendering a present value, consumers MUST preserve the complete display text, escaping only as required by the output format; they MUST NOT split it on whitespace or restrict it to identifier characters. `domain` is display text, not a stable machine identifier. If a consumer embeds it in metadata or uses it in a deduplication key, it MUST retain the exact string, use a lossless encoding, or use a collision-resistant digest; it MUST NOT rely on lowercasing or lossy slugification as the sole identity.
+
**`findings[].suggested-code`** โ optional in the schema but **expected for mechanical findings**. It is a concrete code-replacement payload for the lines indicated by `location`. When present, the string MUST be a literal replacement for the source lines covered by `location.line` (or `location.range` if set) โ i.e., what the file would contain after the fix, with no surrounding diff markers, fences, or commentary. Consumers MAY render it as a one-click suggestion in the delivery surface (for example, a GitHub ```` ```suggestion ```` block).
-Emit `suggested-code` whenever the fix is small, local, and mechanical: deleting unreachable code; replacing one expression (`Count() > 0` โ `not IsEmpty()`); moving a local `Label` to object scope; adding a missing property such as `ToolTip`, `OptionCaption`, or `DataClassification`; replacing a string-concatenated `Error` with a Label-backed call; changing a permission token; or adding a missing `else`/guard branch whose replacement is unambiguous from the surrounding diff. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, prefer adapting the `.good.al` replacement into `suggested-code`.
+Emit `suggested-code` whenever the fix is small, local, and mechanical: deleting unreachable code; replacing one expression (`Count() > 0` โ `not IsEmpty()`); adding a missing property such as `ToolTip`, `OptionCaption`, or `DataClassification`; replacing a string-concatenated `Error` with a Label-backed call; changing a permission token; or adding a missing `else`/guard branch whose replacement is unambiguous from the surrounding diff. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, prefer adapting the `.good.al` replacement into `suggested-code`.
Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but `suggested-code` is omitted, set `findings[].suggested-code-omission-reason` to a short explanation (for example, `requires choosing a real event id` or `fix spans multiple non-contiguous locations`). The `suggested-code` payload supplements `message`; it does not replace the explanation in `message`.
@@ -202,7 +324,7 @@ Omit `suggested-code` only when the appropriate fix depends on context the skill
- `reference` โ the suppressed file (same object shape as `findings[].references`).
- `reason` โ `layer-precedence` when another layer won under READ's precedence rules; `configuration` when the consumer disabled the file's layer.
-**`sub-results`** โ super-skills only. Array of complete findings-reports, one per sub-skill that was invoked (i.e., every sub-skill not listed in `skipped-sub-skills`). Each entry MUST itself conform to this output contract. Leaf skills MUST NOT emit `sub-results`.
+**`sub-results`** โ super-skills only. Array of complete findings-reports, one per sub-skill that was invoked (i.e., every sub-skill not listed in `skipped-sub-skills`). Each entry MUST itself conform to this output contract. Entries MUST appear in the worklist's declared order, regardless of invocation or completion order. Leaf skills MUST NOT emit `sub-results`.
**`skipped-sub-skills`** โ super-skills only. Array of sub-skills that were declared in frontmatter but not invoked. `reason` is `configuration` when the orchestrator disabled the sub-skill, or `not-applicable` when the super-skill's Relevance step ruled it out.
@@ -219,6 +341,26 @@ A **super-skill** is an action skill whose frontmatter declares a non-empty `sub
Composition is flat: a super-skill MAY list only leaf skills (skills without their own `sub-skills`). Nested super-skills are not permitted in v1.
+### Scheduling boundary
+
+The super-skill defines which leaves must run, the input and output contracts,
+and how their results are composed. It does not prescribe a model, concurrency
+limit, retry policy, or telemetry system. Those choices belong to the
+orchestrator.
+
+Each leaf invocation MUST remain a discrete evaluation with its own complete
+findings-report. An orchestrator MAY execute independent leaves serially or
+concurrently, but MUST invoke every worklisted leaf, preserve `sub-results` in
+the declared worklist order, and wait for every invocation to finish before
+performing any super-skill self-review or final rollup. Scheduling MUST NOT
+change relevance, coverage, failure, reference-integrity, or output semantics.
+
+Orchestrators SHOULD generate `skill-index.json` with
+`tools/Build-SkillIndex.ps1` and consume the super-skill's ordered `subSkills`
+from that index instead of parsing Markdown. Action-skill frontmatter remains
+the source of truth; the generated index conforms to
+`schemas/skill-index.schema.json`.
+
### Section interpretation for super-skills
The five required sections still apply. Their meaning shifts from knowledge files to sub-skills:
@@ -226,7 +368,7 @@ The five required sections still apply. Their meaning shifts from knowledge file
- `## Source` โ names the sub-skills invoked (mirrors `sub-skills` in frontmatter).
- `## Relevance` โ rules for deciding which sub-skills apply to the current task. A sub-skill is relevant when its declared `inputs` are satisfied by the orchestrator's provided inputs and the orchestrator has not disabled it via configuration. The super-skill MUST NOT filter sub-skills by task content (for example, by inspecting the diff or the file). Task-level applicability is the sub-skill's own responsibility; sub-skills signal non-applicability by returning `outcome: "not-applicable"` or `outcome: "no-knowledge"`.
- `## Worklist` โ the final list of sub-skills to invoke; the rest go to `skipped-sub-skills`.
-- `## Action` โ invoke each worklisted sub-skill with the appropriate subset of inputs, collect its findings-report verbatim into `sub-results`, and copy its `findings[]` into the super-skill's top-level `findings[]` with `from-sub-skill` set. Findings from a sub-skill with `outcome: "failed"` MUST NOT be copied into the super-skill's top-level `findings[]` and MUST NOT contribute to the super-skill's `summary.counts` (their report is still preserved in `sub-results` for traceability, consistent with DO's rule that consumers ignore a failed skill's findings).
+- `## Action` โ invoke each worklisted sub-skill with the appropriate subset of inputs, collect its findings-report verbatim into `sub-results`, and copy its `findings[]` into the super-skill's top-level `findings[]` with `from-sub-skill` set. All finding fields, including the optional `domain`, are preserved verbatim unless this contract explicitly requires a transformation. Findings from a sub-skill with `outcome: "failed"` MUST NOT be copied into the super-skill's top-level `findings[]` and MUST NOT contribute to the super-skill's `summary.counts` (their report is still preserved in `sub-results` for traceability, consistent with DO's rule that consumers ignore a failed skill's findings).
- `## Output` โ the super-skill's output contract, including `sub-results` and, if any, `skipped-sub-skills`.
### Outcome rollup
@@ -245,7 +387,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
@@ -253,15 +401,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]
@@ -279,7 +428,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.
@@ -288,5 +442,3 @@ Conforms to the DO output contract.
## How orchestrators consume output
An orchestrator invokes an action skill with an input appropriate to the skill's declared `inputs`, receives the JSON output, and maps findings to its delivery surface (PR comments, build gates, IDE diagnostics). The orchestrator MUST NOT interpret skill-specific fields beyond the schema above. Skills that need richer semantics MUST encode them within the schema (for example, by adding structured `message` text) rather than extending the output shape.
-
-
diff --git a/skills/entry.md b/skills/entry.md
index f094aa6..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]
@@ -35,7 +36,7 @@ task-context:
## Preparation โ knowledge index
-Before routing, ensure the knowledge index is current for the **live** clone. The dispatched review skills read `knowledge-index.json` (at the clone root) at their Source step instead of opening every knowledge file โ see READ's [Retrieval workflow](read.md). Because a consumer prunes its clone to policy *before* the agent runs, the index MUST be built over the clone as it exists now, so it lists exactly the articles that survived pruning and never an article the consumer denied:
+Before routing, ensure the knowledge index is current for the **live** clone. The dispatched review skills read `knowledge-index.json` (at the clone root) at their Source step instead of opening every knowledge file โ see READ's [Retrieval workflow](read.md). When a consumer prunes its clone to policy *before* the agent runs, the index MUST be built over the clone as it exists now, so it lists exactly the articles that survived pruning and never an article the consumer denied:
- If `knowledge-index.json` is absent โ or you cannot confirm it reflects the current knowledge tree โ regenerate it by running, from the checkout root:
@@ -44,6 +45,8 @@ Before routing, ensure the knowledge index is current for the **live** clone. Th
```
It defaults to indexing this checkout and writes `knowledge-index.json` at the root in well under a second. When in doubt, rebuild: a sub-second rebuild is always cheaper than a stale or over-listing index, which is a correctness risk.
+- The paths above assume the checkout root is the current directory. A caller that enters Entry from elsewhere โ a plugin host, whose working directory is the user's own project โ MUST resolve them against the BCQuality root it already knows instead. The generator resolves its own root, so invoking it by absolute path indexes and writes the right tree.
+- Pruning is the consumer's job, not Entry's, and not every consumer does it: an installation that ships the whole tree gets no deny guarantee from this step. There, `enabled-layers` narrows discovery only, and the unlisted layers' files remain on disk.
- This is a side step. It MUST NOT change Entry's output โ the dispatch record below is the only thing Entry emits, and build logs are never part of the dispatch JSON.
Generation is **owned by BCQuality**: the generator ships here next to the skills and knowledge it derives from, and the consuming orchestrator neither builds nor knows about the index.
diff --git a/skills/read.md b/skills/read.md
index 8badb97..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.
@@ -115,7 +117,7 @@ Consumers MUST NOT silently treat missing context as a match.
## Citing a knowledge file
-A consumer that produces output referencing a knowledge file MUST cite it by its repo-relative path (for example, `microsoft/knowledge/performance/filter-before-find.md`). Line numbers are not stable references; use the file path only. If a commit SHA is available to the consumer, it SHOULD be included alongside the path.
+A consumer that produces output referencing a knowledge file MUST cite it by its repo-relative path (for example, `microsoft/knowledge/performance/apply-filters-before-iterating.md`). Line numbers are not stable references; use the file path only. If a commit SHA is available to the consumer, it SHOULD be included alongside the path.
## Sample files
@@ -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 9a3b208..c2edab5 100644
--- a/skills/write.md
+++ b/skills/write.md
@@ -9,6 +9,25 @@ title: New Knowledge โ how to author a knowledge file
Anyone โ human or agent โ adding a knowledge file to BCQuality follows this guide. READ is the format specification; WRITE is the authoring guide. This file does not restate the schema; consult READ for field-by-field semantics.
+## Is this a knowledge file?
+
+Before authoring anything, confirm a knowledge file is the right artifact. BCQuality separates *mechanics* from *facts*:
+
+- **Skills** (`*/skills/**`) hold only finder/applier mechanics โ how to discover, filter, worklist, and emit findings. See `skills/do.md`.
+- **Knowledge files** (`*/knowledge/**`) hold every Business-Central-specific fact a skill acts on.
+
+A new BC fact is therefore a knowledge file, never a skill edit. In particular, if you arrived here because a review agent flagged something it should not have (a false positive) or missed something it should have caught, the remedy is a knowledge file โ apply the [admission test](../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
+
+A knowledge file does not have to recommend an action. A **negative clarification** โ "pattern X is *not* a defect, because BC behaves as Y" โ is a first-class knowledge file, authored exactly like a positive rule:
+
+- **Description** states the BC behaviour that makes the pattern legitimate.
+- **Best Practice** tells the reviewer or agent what *not* to flag, and why.
+- **Anti Pattern** describes the false-positive report itself โ the mistaken finding to suppress.
+
+For example, `microsoft/knowledge/error-handling/page-boolean-triggers-default-to-true.md` records that the Boolean page record triggers return `true` by default, so a "missing `exit(true)`" report is not a real defect. It reads as ordinary knowledge; its anti-pattern is the incorrect review comment, not the code.
+
## Before you start
Read `skills/read.md` first. A file that does not conform to READ will be rejected. WRITE assumes READ is already understood.
@@ -37,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.
@@ -61,10 +87,23 @@ Knowledge files do not contain code. Samples live as **sibling files** next to t
## Choosing a layer
-- **`/microsoft/knowledge//`** โ platform-endorsed guidance. Authored or approved by the BC platform team. Use this layer only when the guidance reflects a platform guarantee or official recommendation.
-- **`/community/knowledge//`** โ shared community patterns. The default layer for contributions from outside the platform team. Content here can be promoted to `/microsoft/` once it proves itself.
+In the shared upstream layers, keep an action skill and the canonical knowledge it acts on in the same layer. The action skill's ownership determines the destination; the author's affiliation does not. Do not use `/community/knowledge/` as a staging area for articles in a domain already owned by a Microsoft-endorsed skill. A cross-layer split is acceptable only as a short-lived migration state while the skill or corpus is being promoted. Custom overrides are intentionally exempt because they extend shared skills from a consumer fork.
+
+- **`/microsoft/knowledge//`** โ guidance owned by a Microsoft-endorsed action skill. It has been approved as platform-endorsed guidance, whether authored by Microsoft or contributed by the community.
+- **`/community/knowledge//`** โ knowledge that accompanies a community-owned action skill. Promote the knowledge with the skill when that skill becomes Microsoft-endorsed.
- **`/custom/knowledge//`** โ partner or customer overrides. Generally does not appear in the BCQuality repository itself; `/custom/` lives in consumer repositories.
+### Writing to `/custom/` โ fork precondition
+
+The `/custom/` layer is **empty by default** in the upstream `microsoft/BCQuality` repository โ it ships as a template (`README.md` plus `.gitkeep` placeholders) and is meant to be populated only inside a **fork or consumer clone** that an organization controls. Custom content is partner- or customer-specific by definition and is never accepted upstream.
+
+Before authoring or scaffolding any file under `/custom/knowledge/` or `/custom/skills/`, an author โ human or agent โ MUST confirm the working repository is **not** `microsoft/BCQuality`:
+
+- Check the `origin` remote: `git remote get-url origin`. If it points at `github.com/microsoft/BCQuality`, stop โ you are in the upstream repo, not a fork.
+- If you are in the upstream repo, do not write the 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.
+
## Pre-PR checklist
Before opening a pull request:
@@ -74,7 +113,11 @@ Before opening a pull request:
- No fenced code blocks.
- File is under 100 lines.
- File covers one concern.
+- Frontmatter `domain` exactly matches the containing domain folder.
- File is in the correct layer and domain folder.
- Name is kebab-case and descriptive.
+- Every companion sample 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..022898e
--- /dev/null
+++ b/tools/Build-SkillIndex.ps1
@@ -0,0 +1,247 @@
+<#
+.SYNOPSIS
+ Builds the machine-readable BCQuality action-skill index.
+
+.DESCRIPTION
+ Action-skill frontmatter remains the source of truth. This script emits the
+ versioned JSON contract orchestrators consume so they do not need to parse
+ Markdown or duplicate composition rules.
+
+.PARAMETER BCQualityRoot
+ BCQuality repository or filtered content root.
+
+.PARAMETER IndexPath
+ Output path. Defaults to /skill-index.json.
+
+.OUTPUTS
+ Returns the number of indexed action skills.
+#>
+[CmdletBinding()]
+param(
+ [string] $BCQualityRoot,
+ [string] $IndexPath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+if (-not $BCQualityRoot) {
+ $BCQualityRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+}
+if (-not (Test-Path -LiteralPath $BCQualityRoot -PathType Container)) {
+ throw "BCQuality root not found: $BCQualityRoot"
+}
+$BCQualityRoot = (Resolve-Path -LiteralPath $BCQualityRoot).Path
+if (-not $IndexPath) {
+ $IndexPath = Join-Path $BCQualityRoot 'skill-index.json'
+}
+
+function Get-RelativePath {
+ param([string] $Root, [string] $Full)
+
+ return ($Full.Substring($Root.Length).TrimStart([char]'/', [char]'\') -replace '\\', '/')
+}
+
+function Get-Sha256 {
+ param([byte[]] $Bytes)
+
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try {
+ return ([BitConverter]::ToString($sha.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant()
+ }
+ finally {
+ $sha.Dispose()
+ }
+}
+
+function Get-ValueSha256 {
+ param([Parameter(Mandatory)] $Value)
+
+ return Get-Sha256 -Bytes ([Text.Encoding]::UTF8.GetBytes(
+ (ConvertTo-Json -InputObject $Value -Depth 12 -Compress)
+ ))
+}
+
+function ConvertFrom-SkillFrontmatter {
+ param(
+ [string] $Path,
+ [string] $Text
+ )
+
+ $lines = [regex]::Split($Text.TrimStart([char]0xfeff), '\r\n|\n|\r')
+ if ($lines.Count -lt 3 -or $lines[0].Trim() -ne '---') {
+ throw [IO.InvalidDataException]::new("Missing frontmatter in '$Path'.")
+ }
+
+ $end = -1
+ for ($i = 1; $i -lt $lines.Count; $i++) {
+ if ($lines[$i].Trim() -eq '---') {
+ $end = $i
+ break
+ }
+ }
+ if ($end -lt 0) {
+ throw [IO.InvalidDataException]::new("Unterminated frontmatter in '$Path'.")
+ }
+
+ $frontmatter = [ordered]@{}
+ for ($i = 1; $i -lt $end; $i++) {
+ $line = $lines[$i]
+ if ($line -notmatch '^([a-zA-Z][\w-]*)\s*:\s*(.*)$') {
+ continue
+ }
+
+ $key = $Matches[1]
+ $value = $Matches[2].Trim()
+ if ($value -eq '') {
+ $items = [System.Collections.Generic.List[string]]::new()
+ while ($i + 1 -lt $end -and $lines[$i + 1] -match '^\s+-\s+(.+?)\s*$') {
+ $i++
+ $items.Add($Matches[1].Trim().Trim('"', "'")) | Out-Null
+ }
+ $frontmatter[$key] = @($items)
+ continue
+ }
+
+ if ($value -match '^\[(.*)\]$') {
+ $inner = $Matches[1].Trim()
+ $values = [System.Collections.Generic.List[object]]::new()
+ if ($inner) {
+ foreach ($item in $inner -split '\s*,\s*') {
+ $normalized = $item.Trim().Trim('"', "'")
+ $number = 0
+ if ($key -eq 'bc-version' -and [int]::TryParse($normalized, [ref]$number)) {
+ $values.Add($number) | Out-Null
+ }
+ else {
+ $values.Add($normalized) | Out-Null
+ }
+ }
+ }
+ $frontmatter[$key] = [object[]]@($values)
+ continue
+ }
+
+ $frontmatter[$key] = $value.Trim('"', "'")
+ }
+
+ return $frontmatter
+}
+
+$records = [System.Collections.Generic.List[object]]::new()
+$recordsByPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
+$sourceManifest = [System.Collections.Generic.List[object]]::new()
+
+foreach ($layer in 'microsoft', 'community', 'custom') {
+ $skillsRoot = Join-Path $BCQualityRoot (Join-Path $layer 'skills')
+ if (-not (Test-Path -LiteralPath $skillsRoot -PathType Container)) {
+ continue
+ }
+
+ foreach ($file in Get-ChildItem -LiteralPath $skillsRoot -Recurse -File -Filter '*.md' | Sort-Object FullName) {
+ $bytes = [IO.File]::ReadAllBytes($file.FullName)
+ try {
+ $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes)
+ }
+ catch [Text.DecoderFallbackException] {
+ throw [IO.InvalidDataException]::new("Invalid UTF-8 in '$($file.FullName)'.", $_.Exception)
+ }
+
+ $frontmatter = ConvertFrom-SkillFrontmatter -Path $file.FullName -Text $text
+ if ($frontmatter['kind'] -ne 'action-skill') {
+ continue
+ }
+
+ foreach ($required in 'id', 'version', 'title', 'description', 'inputs', 'outputs') {
+ if (-not $frontmatter.Contains($required) -or $null -eq $frontmatter[$required] -or
+ ([string]$frontmatter[$required]).Trim() -eq '') {
+ throw [IO.InvalidDataException]::new(
+ "Action skill '$($file.FullName)' is missing required frontmatter '$required'."
+ )
+ }
+ }
+
+ $path = Get-RelativePath -Root $BCQualityRoot -Full $file.FullName
+ $sourceSha256 = Get-Sha256 -Bytes $bytes
+ $version = 0
+ if (-not [int]::TryParse([string]$frontmatter['version'], [ref]$version) -or $version -le 0) {
+ throw [IO.InvalidDataException]::new("Action skill '$path' has an invalid version.")
+ }
+
+ $subSkills = @()
+ if ($frontmatter.Contains('sub-skills')) {
+ $subSkills = @($frontmatter['sub-skills'])
+ if (-not $subSkills.Count) {
+ throw [IO.InvalidDataException]::new("Super-skill '$path' has an empty sub-skills list.")
+ }
+ }
+
+ $record = [pscustomobject][ordered]@{
+ path = $path
+ layer = $layer
+ id = [string]$frontmatter['id']
+ version = $version
+ title = [string]$frontmatter['title']
+ description = [string]$frontmatter['description']
+ inputs = [string[]]@($frontmatter['inputs'])
+ outputs = [string[]]@($frontmatter['outputs'])
+ filters = [ordered]@{
+ 'bc-version' = [object[]]$(if ($frontmatter.Contains('bc-version')) { $frontmatter['bc-version'] })
+ technologies = [string[]]$(if ($frontmatter.Contains('technologies')) { $frontmatter['technologies'] })
+ countries = [string[]]$(if ($frontmatter.Contains('countries')) { $frontmatter['countries'] })
+ 'application-area' = [string[]]$(if ($frontmatter.Contains('application-area')) { $frontmatter['application-area'] })
+ }
+ subSkills = [string[]]$subSkills
+ sourceSha256 = $sourceSha256
+ }
+
+ if (-not $recordsByPath.TryAdd($path, $record)) {
+ throw "Duplicate action-skill path: $path"
+ }
+ $records.Add($record) | Out-Null
+ $sourceManifest.Add([ordered]@{ path = $path; sha256 = $sourceSha256 }) | Out-Null
+ }
+}
+
+$ids = @($records | Group-Object id | Where-Object Count -gt 1)
+if ($ids.Count) {
+ throw "Duplicate action-skill IDs: $($ids.Name -join ', ')"
+}
+
+foreach ($record in $records) {
+ $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ foreach ($subSkillPath in @($record.subSkills)) {
+ if (-not $seen.Add($subSkillPath)) {
+ throw "Super-skill '$($record.path)' declares duplicate sub-skill '$subSkillPath'."
+ }
+ if (-not $recordsByPath.ContainsKey($subSkillPath)) {
+ throw "Super-skill '$($record.path)' references missing action skill '$subSkillPath'."
+ }
+
+ $leaf = $recordsByPath[$subSkillPath]
+ if (@($leaf.subSkills).Count) {
+ throw "Nested super-skills are not supported: '$($record.path)' references '$subSkillPath'."
+ }
+ if (@($leaf.outputs).Count -ne 1 -or $leaf.outputs[0] -ne 'findings-report') {
+ throw "Sub-skill '$subSkillPath' must produce findings-report."
+ }
+ }
+}
+
+$index = [ordered]@{
+ version = 1
+ generatedAt = (Get-Date).ToUniversalTime().ToString('o')
+ skillCount = $records.Count
+ sourceSnapshot = Get-ValueSha256 -Value @($sourceManifest)
+ skills = @($records)
+}
+
+$parent = Split-Path -Parent $IndexPath
+if ($parent -and -not (Test-Path -LiteralPath $parent)) {
+ New-Item -ItemType Directory -Path $parent -Force | Out-Null
+}
+Set-Content -LiteralPath $IndexPath -Value (
+ ConvertTo-Json -InputObject $index -Depth 12 -Compress
+) -Encoding utf8NoBOM
+
+return $records.Count
diff --git a/tools/Get-KnowledgeArticles.ps1 b/tools/Get-KnowledgeArticles.ps1
new file mode 100644
index 0000000..cdd112a
--- /dev/null
+++ b/tools/Get-KnowledgeArticles.ps1
@@ -0,0 +1,187 @@
+<#
+.SYNOPSIS
+ Reads a bounded prefix of exact article or sample paths without altering bodies.
+.DESCRIPTION
+ The UTF-8 byte size bound covers the complete serialized JSON plus its output
+ newline. A body that cannot fit fails explicitly; it is never summarized or
+ truncated. Samples are loaded only with -Samples and must be linked by their
+ sibling article using READ's exact link convention.
+#>
+#requires -Version 7.2
+[CmdletBinding()]
+param(
+ [ValidateNotNullOrEmpty()] [string] $BCQualityRoot = (Split-Path $PSScriptRoot -Parent),
+ [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string[]] $Paths,
+ [ValidateRange(1, 8)] [int] $MaxArticles = 8,
+ [ValidateRange(1024, 16000)] [int] $MaxBytes = 16000,
+ [ValidateSet('microsoft', 'community', 'custom')]
+ [AllowEmptyCollection()] [string[]] $EnabledLayers = @('microsoft', 'community', 'custom'),
+ [string] $IndexPath,
+ [ValidatePattern('^[a-f0-9]{64}$')] [string] $Snapshot,
+ [switch] $Samples
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1')
+. (Join-Path $PSScriptRoot 'Bounded-Results.ps1')
+
+$BCQualityRoot = Resolve-KnowledgeRoot $BCQualityRoot
+if (-not $IndexPath) {
+ $IndexPath = Join-Path $BCQualityRoot 'knowledge-index.json'
+}
+if ($Paths.Count -gt $MaxArticles) {
+ throw "Paths count $($Paths.Count) exceeds MaxArticles=$MaxArticles. Split the worklist into stable chunks of at most $MaxArticles exact paths."
+}
+if ($null -eq $EnabledLayers) {
+ throw 'EnabledLayers must be an array.'
+}
+if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or
+ @($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) {
+ throw 'EnabledLayers must contain unique canonical lowercase layer names.'
+}
+
+$recovery = "Run Entry preparation once before dispatch, or use READ's bounded native-file fallback. Do not rebuild in a leaf."
+$preparedIndex = Read-PreparedKnowledgeIndex -IndexPath $IndexPath -Recovery $recovery
+$index = $preparedIndex.index
+$byPath = $preparedIndex.byPath
+$unrestricted = $index.enabledLayers.Count -eq 0 -or
+ ($index.enabledLayers.Count -eq 1 -and $null -eq $index.enabledLayers[0])
+$indexedLayers = @(
+ if ($unrestricted) { 'microsoft', 'community', 'custom' } else { $index.enabledLayers }
+)
+if (@($EnabledLayers | Where-Object { $_ -cnotin $indexedLayers }).Count) {
+ throw "Index layer coverage does not cover EnabledLayers. $recovery"
+}
+
+$resolved = [Collections.Generic.List[string]]::new()
+$records = [Collections.Generic.List[object]]::new()
+$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+$articleTexts = [Collections.Generic.Dictionary[string, string]]::new([StringComparer]::Ordinal)
+$sampleContents = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
+foreach ($path in $Paths) {
+ $kind = if ($Samples) { 'sample' } else { 'article' }
+ $fullPath = Resolve-KnowledgePath -Root $BCQualityRoot -Path $path -Kind $kind
+ if ($path.Split('/')[0] -cnotin $EnabledLayers) {
+ throw "Layer disabled for path: $path"
+ }
+ if (-not $seen.Add($path)) {
+ throw "Duplicate requested path: $path"
+ }
+ if ($Samples) {
+ $articlePath = $path -replace '\.(good|bad)\.[a-z0-9]+$', '.md'
+ if (-not $byPath.ContainsKey($articlePath)) {
+ throw "Sample article is absent from the prepared index: $articlePath"
+ }
+ $fullArticlePath = Resolve-KnowledgePath -Root $BCQualityRoot -Path $articlePath
+ if (-not $articleTexts.ContainsKey($articlePath)) {
+ $articleContent = Read-KnowledgeText -Path $fullArticlePath
+ if ($articleContent.sha256 -cne $byPath[$articlePath].sourceSha256) {
+ throw "Selected article hash does not match the prepared index: $articlePath"
+ }
+ $articleTexts.Add($articlePath, $articleContent.text)
+ }
+ Assert-SampleLink -ArticleText $articleTexts[$articlePath] -SamplePath $fullPath
+ $sampleContent = Read-KnowledgeText -Path $fullPath
+ $sampleContents.Add($path, $sampleContent)
+ $records.Add([ordered]@{
+ path = $path
+ articlePath = $articlePath
+ articleSha256 = $byPath[$articlePath].sourceSha256
+ sampleSha256 = $sampleContent.sha256
+ sampleBytes = $sampleContent.bytes
+ })
+ }
+ else {
+ if (-not $byPath.ContainsKey($path)) {
+ throw "Selected article is absent from the prepared index: $path"
+ }
+ $records.Add([ordered]@{
+ path = $path
+ expectedSha256 = $byPath[$path].sourceSha256
+ })
+ }
+ $resolved.Add($fullPath)
+}
+
+$requestSnapshot = Get-ResultSnapshot -Value ([ordered]@{
+ root = $BCQualityRoot
+ preparedIndexSha256 = $preparedIndex.content.sha256
+ kind = if ($Samples) { 'samples' } else { 'articles' }
+ enabledLayers = @($EnabledLayers)
+ files = @($records)
+})
+if ($Snapshot -and $Snapshot -cne $requestSnapshot) {
+ throw 'Article snapshot changed or continuation belongs to another exact path batch. Discard partial results and restart.'
+}
+
+$articles = [Collections.Generic.List[object]]::new()
+function ConvertTo-BatchJson {
+ param([int] $ReadCount)
+
+ $remaining = @(
+ if ($ReadCount -lt $Paths.Count) {
+ $Paths[$ReadCount..($Paths.Count - 1)]
+ }
+ )
+ $remainingRecords = @(
+ if ($ReadCount -lt $records.Count) {
+ $records[$ReadCount..($records.Count - 1)]
+ }
+ )
+ $continuation = if ($remaining.Count) {
+ [ordered]@{
+ snapshot = Get-ResultSnapshot -Value ([ordered]@{
+ root = $BCQualityRoot
+ preparedIndexSha256 = $preparedIndex.content.sha256
+ kind = if ($Samples) { 'samples' } else { 'articles' }
+ enabledLayers = @($EnabledLayers)
+ files = $remainingRecords
+ })
+ }
+ }
+ else {
+ $null
+ }
+ return [ordered]@{
+ version = 1
+ kind = if ($Samples) { 'samples' } else { 'articles' }
+ snapshot = $requestSnapshot
+ requestedCount = $Paths.Count
+ returnedCount = $ReadCount
+ complete = ($ReadCount -eq $Paths.Count)
+ articles = @($articles)
+ remainingPaths = $remaining
+ continuation = $continuation
+ } | ConvertTo-Json -Depth 8 -Compress
+}
+
+$json = ''
+for ($i = 0; $i -lt [Math]::Min($MaxArticles, $Paths.Count); $i++) {
+ $content = if ($Samples) {
+ $sampleContents[$Paths[$i]]
+ }
+ else {
+ Read-KnowledgeText -Path $resolved[$i]
+ }
+ if (-not $Samples -and $content.sha256 -cne $records[$i].expectedSha256) {
+ throw "Selected article hash does not match the prepared index: $($Paths[$i])"
+ }
+ $articles.Add([ordered]@{
+ path = $Paths[$i]
+ bytes = $content.bytes
+ sha256 = $content.sha256
+ body = $content.text
+ })
+ $next = ConvertTo-BatchJson -ReadCount ($i + 1)
+ if ((Get-SerializedByteCount -Json $next) -gt $MaxBytes) {
+ $articles.RemoveAt($articles.Count - 1)
+ if ($i -eq 0) {
+ throw "No complete body plus continuation fits MaxBytes=$MaxBytes at $($Paths[$i]). Use a smaller exact path batch or READ's bounded native-file fallback; never truncate."
+ }
+ break
+ }
+ $json = $next
+}
+
+$json
diff --git a/tools/Knowledge-Retrieval.ps1 b/tools/Knowledge-Retrieval.ps1
new file mode 100644
index 0000000..7007868
--- /dev/null
+++ b/tools/Knowledge-Retrieval.ps1
@@ -0,0 +1,298 @@
+# Shared filesystem guards for catalog and exact article retrieval.
+Set-StrictMode -Version Latest
+
+function Resolve-KnowledgeRoot {
+ param([string] $Root)
+
+ $item = Get-Item -LiteralPath $Root -Force -ErrorAction Stop
+ if ($item.PSProvider.Name -ne 'FileSystem' -or -not $item.PSIsContainer) {
+ throw "BCQuality root must be a filesystem directory: $Root"
+ }
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ throw "Linked BCQuality roots are not supported: $Root"
+ }
+ return $item.FullName
+}
+
+function Assert-KnowledgePath {
+ param(
+ [string] $Path,
+ [ValidateSet('article', 'sample')] [string] $Kind = 'article'
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Path) -or
+ $Path -cnotmatch '^(microsoft|community|custom)/knowledge/[^/]+/.+' -or
+ $Path -match '[\\:*?"<>|\x00-\x1f]' -or
+ @($Path.Split('/') | Where-Object { $_ -in '', '.', '..' -or $_ -match '[. ]$' }).Count) {
+ throw "Invalid knowledge path: $Path"
+ }
+ if (($Kind -eq 'article' -and -not $Path.EndsWith('.md', [StringComparison]::Ordinal)) -or
+ ($Kind -eq 'sample' -and $Path -cnotmatch '\.(good|bad)\.[a-z0-9]+$')) {
+ throw "Expected an exact $Kind path: $Path"
+ }
+}
+
+function Resolve-KnowledgePath {
+ param(
+ [string] $Root,
+ [string] $Path,
+ [ValidateSet('article', 'sample')] [string] $Kind = 'article'
+ )
+
+ Assert-KnowledgePath -Path $Path -Kind $Kind
+ $current = $Root
+ foreach ($part in $Path.Split('/')) {
+ $items = @(
+ Get-ChildItem -LiteralPath $current -Filter $part -Force -ErrorAction Stop |
+ Where-Object Name -CEQ $part
+ )
+ if ($items.Count -ne 1) {
+ throw "Knowledge path does not exist with exact casing: $Path"
+ }
+ $item = $items[0]
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ throw "Linked knowledge paths are not supported: $Path"
+ }
+ $current = $item.FullName
+ }
+ if ($item.PSIsContainer) {
+ throw "Knowledge path is not a file: $Path"
+ }
+ return $item.FullName
+}
+
+function Read-KnowledgeText {
+ param([string] $Path)
+
+ $bytes = [IO.File]::ReadAllBytes($Path)
+ try {
+ $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes)
+ }
+ catch {
+ throw "Knowledge file is not valid strict UTF-8: $Path"
+ }
+ return [pscustomobject]@{
+ text = $text
+ bytes = $bytes.Length
+ sha256 = [Convert]::ToHexString(
+ [Security.Cryptography.SHA256]::HashData($bytes)
+ ).ToLowerInvariant()
+ }
+}
+
+function Get-NormalizedKnowledgeVersions {
+ param([string[]] $Values)
+
+ foreach ($value in $Values) {
+ if ($value -match '^"([^"]*)"$' -or $value -match "^'([^']*)'$") {
+ $Matches[1]
+ }
+ else {
+ $value
+ }
+ }
+}
+
+function Get-KnowledgeMetadataProblem {
+ param([Collections.IDictionary] $Row)
+
+ if ($Row['parsed'] -isnot [bool] -or -not $Row['parsed']) {
+ return 'unparsed frontmatter'
+ }
+ if ($Row['domain'] -isnot [string] -or
+ $Row['domain'] -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$') {
+ return 'missing/invalid domain'
+ }
+ foreach ($field in @('bc-version', 'technologies', 'countries', 'application-area', 'keywords')) {
+ if ($Row[$field] -isnot [array] -or -not $Row[$field].Count) {
+ return "missing/invalid $field"
+ }
+ foreach ($value in $Row[$field]) {
+ if ($value -isnot [string] -or [string]::IsNullOrWhiteSpace($value)) {
+ return "invalid $field value"
+ }
+ }
+ }
+ foreach ($field in @('title', 'description')) {
+ if ($Row[$field] -isnot [string] -or
+ [string]::IsNullOrWhiteSpace($Row[$field]) -or
+ $Row[$field] -match '[\r\n]') {
+ return "missing/invalid $field"
+ }
+ }
+
+ $versions = @(Get-NormalizedKnowledgeVersions -Values $Row['bc-version'])
+ if ($versions -ccontains 'all') {
+ if ($versions.Count -ne 1) {
+ return 'mixed bc-version sentinel'
+ }
+ }
+ elseif ($versions.Count -eq 1 -and $versions[0] -match '^(\d+)\.\.(\d+)?$') {
+ $start = [bigint]::Parse($Matches[1])
+ if ($start -le 0 -or ($Matches[2] -and [bigint]::Parse($Matches[2]) -le 0)) {
+ return 'invalid bc-version range bound'
+ }
+ if ($Matches[2] -and $start -gt [bigint]::Parse($Matches[2])) {
+ return 'descending bc-version range'
+ }
+ }
+ else {
+ foreach ($version in $versions) {
+ if ($version -notmatch '^\d+$' -or [bigint]::Parse($version) -le 0) {
+ return 'invalid bc-version'
+ }
+ }
+ }
+ if (@($Row.technologies | Where-Object { $_ -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$' }).Count) {
+ return 'invalid technologies'
+ }
+ if ($Row.technologies -ccontains 'all') {
+ return 'invalid technologies sentinel'
+ }
+ if ($Row.countries -ccontains 'w1') {
+ if ($Row.countries.Count -ne 1) {
+ return 'mixed countries sentinel'
+ }
+ }
+ elseif (@($Row.countries | Where-Object { $_ -cnotmatch '^[a-z]{2}$' }).Count) {
+ return 'invalid countries'
+ }
+ if (@($Row['application-area'] | Where-Object { $_ -cnotmatch '^(all|[a-z0-9]+(-[a-z0-9]+)*)$' }).Count) {
+ return 'invalid application-area'
+ }
+ if ($Row['application-area'] -ccontains 'all' -and $Row['application-area'].Count -ne 1) {
+ return 'mixed application-area sentinel'
+ }
+ if (@($Row.keywords | Where-Object { $_ -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$' }).Count) {
+ return 'invalid keywords'
+ }
+ return ''
+}
+
+function Get-PreparedManifestSha256 {
+ param(
+ [string[]] $Paths,
+ [Collections.Generic.Dictionary[string, object]] $ByPath
+ )
+
+ $hash = [Security.Cryptography.IncrementalHash]::CreateHash(
+ [Security.Cryptography.HashAlgorithmName]::SHA256
+ )
+ try {
+ $hash.AppendData([byte[]][char]'[')
+ for ($i = 0; $i -lt $Paths.Count; $i++) {
+ if ($i) {
+ $hash.AppendData([byte[]][char]',')
+ }
+ $row = [ordered]@{
+ path = $Paths[$i]
+ sha256 = $ByPath[$Paths[$i]].sourceSha256
+ }
+ $hash.AppendData([Text.Encoding]::UTF8.GetBytes(
+ (ConvertTo-Json -InputObject $row -Depth 8 -Compress)
+ ))
+ }
+ $hash.AppendData([byte[]][char]']')
+ return [Convert]::ToHexString($hash.GetHashAndReset()).ToLowerInvariant()
+ }
+ finally {
+ $hash.Dispose()
+ }
+}
+
+function Read-PreparedKnowledgeIndex {
+ param(
+ [string] $IndexPath,
+ [string] $Recovery
+ )
+
+ if (-not (Test-Path -LiteralPath $IndexPath -PathType Leaf)) {
+ throw "Knowledge index missing: $IndexPath. $Recovery"
+ }
+ $indexItem = Get-Item -LiteralPath $IndexPath -Force -ErrorAction Stop
+ if ($indexItem.PSProvider.Name -ne 'FileSystem' -or
+ ($indexItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
+ throw "Knowledge index must be an unlinked filesystem file: $IndexPath. $Recovery"
+ }
+
+ $content = Read-KnowledgeText -Path $indexItem.FullName
+ try {
+ $index = $content.text.TrimStart([char]0xfeff) |
+ ConvertFrom-Json -AsHashtable -ErrorAction Stop
+ }
+ catch {
+ throw "Malformed knowledge index JSON: $($_.Exception.Message). $Recovery"
+ }
+ if ($index -isnot [Collections.IDictionary] -or
+ $index.version -ne 1 -or
+ $index.articles -isnot [array] -or
+ $index.articleCount -ne $index.articles.Count -or
+ $index.enabledLayers -isnot [array] -or
+ $index.knowledgeAllow -isnot [array] -or
+ $index.knowledgeDeny -isnot [array] -or
+ $index.sourceSnapshot -isnot [string] -or
+ $index.sourceSnapshot -cnotmatch '^[a-f0-9]{64}$') {
+ throw "Invalid knowledge index envelope. $Recovery"
+ }
+
+ $generatedAt = [DateTimeOffset]::MinValue
+ if ($index.generatedAt -is [DateTime]) {
+ $generatedAt = [DateTimeOffset]$index.generatedAt
+ }
+ elseif (-not [DateTimeOffset]::TryParse(
+ [string]$index.generatedAt,
+ [Globalization.CultureInfo]::InvariantCulture,
+ [Globalization.DateTimeStyles]::RoundtripKind,
+ [ref]$generatedAt
+ )) {
+ throw "Invalid knowledge index generatedAt. $Recovery"
+ }
+ if ($generatedAt -gt [DateTimeOffset]::UtcNow.AddMinutes(1)) {
+ throw "Invalid knowledge index generatedAt. $Recovery"
+ }
+
+ $byPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
+ foreach ($row in $index.articles) {
+ if ($row -isnot [Collections.IDictionary] -or $row.path -isnot [string]) {
+ throw "Index row has no exact path. $Recovery"
+ }
+ Assert-KnowledgePath -Path $row.path
+ if ($row.layer -cne $row.path.Split('/')[0] -or
+ $row.layer -cnotin @('microsoft', 'community', 'custom')) {
+ throw "Invalid index layer: $($row.path). $Recovery"
+ }
+ if ($row.sourceSha256 -isnot [string] -or
+ $row.sourceSha256 -cnotmatch '^[a-f0-9]{64}$') {
+ throw "Invalid source hash in knowledge index: $($row.path). $Recovery"
+ }
+ if (-not $byPath.TryAdd($row.path, $row)) {
+ throw "Duplicate index path: $($row.path). $Recovery"
+ }
+ }
+
+ $paths = [string[]]@($byPath.Keys)
+ [Array]::Sort($paths, [StringComparer]::Ordinal)
+ if ((Get-PreparedManifestSha256 -Paths $paths -ByPath $byPath) -cne $index.sourceSnapshot) {
+ throw "Stale or internally inconsistent prepared index snapshot. $Recovery"
+ }
+
+ return [pscustomobject]@{
+ index = $index
+ content = $content
+ byPath = $byPath
+ paths = $paths
+ }
+}
+
+function Assert-SampleLink {
+ param(
+ [string] $ArticleText,
+ [string] $SamplePath
+ )
+
+ $sampleName = [IO.Path]::GetFileName($SamplePath)
+ $expected = '[`' + $sampleName + '`](' + $sampleName + ')'
+ if (-not $ArticleText.Contains($expected, [StringComparison]::Ordinal)) {
+ throw "Sample is not linked by its article using the READ convention: $sampleName"
+ }
+}
diff --git a/tools/Search-Knowledge.ps1 b/tools/Search-Knowledge.ps1
new file mode 100644
index 0000000..ade280e
--- /dev/null
+++ b/tools/Search-Knowledge.ps1
@@ -0,0 +1,244 @@
+<#
+.SYNOPSIS
+ Returns bounded pages of every domain/layer/READ-applicable catalog row.
+.DESCRIPTION
+ Consumes Entry's prepared index read-only. Results are never ranked, sampled,
+ top-k limited, deduplicated by basename, or narrowed by query text. Omit an
+ unknown task dimension; an explicit empty array is a known empty set.
+#>
+#requires -Version 7.2
+[CmdletBinding()]
+param(
+ [ValidateNotNullOrEmpty()] [string] $BCQualityRoot = (Split-Path $PSScriptRoot -Parent),
+ [Parameter(Mandatory)] [ValidateNotNullOrEmpty()]
+ [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] [string] $Domain,
+ [ValidateSet('microsoft', 'community', 'custom')]
+ [AllowEmptyCollection()] [string[]] $EnabledLayers = @('microsoft', 'community', 'custom'),
+ [ValidateRange(1, 2147483647)] [int] $BCVersion,
+ [AllowEmptyCollection()] [string[]] $Technologies,
+ [AllowEmptyCollection()] [string[]] $Countries,
+ [AllowEmptyCollection()] [string[]] $ApplicationAreas,
+ [switch] $ExcludeConditional,
+ [string] $IndexPath,
+ [ValidateRange(1024, 16000)] [int] $MaxBytes = 16000,
+ [ValidateRange(0, 2147483647)] [int] $Offset = 0,
+ [ValidatePattern('^[a-f0-9]{64}$')] [string] $Snapshot
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1')
+. (Join-Path $PSScriptRoot 'Bounded-Results.ps1')
+
+$BCQualityRoot = Resolve-KnowledgeRoot $BCQualityRoot
+if (-not $IndexPath) {
+ $IndexPath = Join-Path $BCQualityRoot 'knowledge-index.json'
+}
+if ($null -eq $EnabledLayers) {
+ throw 'EnabledLayers must be an array; use an empty array to disable all layers.'
+}
+if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or
+ @($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) {
+ throw 'EnabledLayers must contain unique canonical lowercase layer names.'
+}
+
+$context = [ordered]@{}
+foreach ($pair in @(
+ @('BCVersion', 'bc-version'),
+ @('Technologies', 'technologies'),
+ @('Countries', 'countries'),
+ @('ApplicationAreas', 'application-area')
+)) {
+ if (-not $PSBoundParameters.ContainsKey($pair[0])) {
+ continue
+ }
+ $value = $PSBoundParameters[$pair[0]]
+ if ($null -eq $value) {
+ throw "Omit unknown context; do not pass null for $($pair[0])."
+ }
+ if ($pair[0] -ne 'BCVersion') {
+ foreach ($entry in $value) {
+ if ([string]::IsNullOrWhiteSpace($entry) -or $entry -cne $entry.Trim()) {
+ throw "Invalid context value for $($pair[0]): '$entry'"
+ }
+ }
+ }
+ $context[$pair[1]] = $value
+}
+if ($context.Contains('technologies') -and $context['technologies'] -ccontains 'all') {
+ throw "Technologies has no 'all' sentinel. Omit unknown context."
+}
+
+$recovery = "Run Entry preparation once before dispatch, or use READ's path-discovery fallback. Do not rebuild in a leaf."
+$preparedIndex = Read-PreparedKnowledgeIndex -IndexPath $IndexPath -Recovery $recovery
+$index = $preparedIndex.index
+$indexContent = $preparedIndex.content
+$byPath = $preparedIndex.byPath
+$paths = $preparedIndex.paths
+
+# The v1 generator historically serialized an omitted EnabledLayers parameter as [null].
+$unrestricted = $index.enabledLayers.Count -eq 0 -or
+ ($index.enabledLayers.Count -eq 1 -and $null -eq $index.enabledLayers[0])
+$indexedLayers = @(
+ if ($unrestricted) {
+ 'microsoft', 'community', 'custom'
+ }
+ else {
+ $index.enabledLayers
+ }
+)
+if (@($indexedLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or
+ @($indexedLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count -or
+ @($EnabledLayers | Where-Object { $_ -cnotin $indexedLayers }).Count) {
+ throw "Index layer coverage does not cover EnabledLayers. $recovery"
+}
+
+foreach ($path in $paths) {
+ $row = $byPath[$path]
+ if ($row.layer -cnotin $indexedLayers) {
+ throw "Index row layer is outside index coverage: $path. $recovery"
+ }
+ $problem = Get-KnowledgeMetadataProblem -Row $row
+ if ($problem) {
+ throw "Malformed knowledge index row at ${path}: $problem. $recovery"
+ }
+}
+
+$defaults = [ordered]@{
+ 'bc-version' = @('all')
+ technologies = @('al')
+ countries = @('w1')
+ 'application-area' = @('all')
+}
+$candidates = [Collections.Generic.List[object]]::new()
+$excluded = [Collections.Generic.List[object]]::new()
+foreach ($path in $paths) {
+ $row = $byPath[$path]
+ if ($row.domain -cne $Domain) {
+ continue
+ }
+
+ $unknown = [Collections.Generic.List[string]]::new()
+ $matchesContext = $true
+ foreach ($field in $defaults.Keys) {
+ $values = $row[$field]
+ if ($field -eq 'bc-version') {
+ $values = @(Get-NormalizedKnowledgeVersions -Values $values)
+ }
+ $sentinel = switch ($field) {
+ 'bc-version' { 'all' }
+ 'countries' { 'w1' }
+ 'application-area' { 'all' }
+ default { '' }
+ }
+ if ($sentinel -and $values -ccontains $sentinel) {
+ continue
+ }
+ if (-not $context.Contains($field)) {
+ $unknown.Add($field)
+ continue
+ }
+
+ $target = $context[$field]
+ $matched = $false
+ if ($field -eq 'bc-version') {
+ # Compare as bigint on both sides: metadata validation accepts bounds
+ # wider than Int32, and an int left operand would coerce them down.
+ $targetVersion = [bigint]$target
+ if ($values.Count -eq 1 -and $values[0] -match '^(\d+)\.\.(\d+)?$') {
+ $matched = $targetVersion -ge [bigint]::Parse($Matches[1]) -and
+ (-not $Matches[2] -or $targetVersion -le [bigint]::Parse($Matches[2]))
+ }
+ else {
+ $matched = @($values | Where-Object { [bigint]::Parse($_) -eq $targetVersion }).Count -gt 0
+ }
+ }
+ else {
+ $matched = @($values | Where-Object { $target -ccontains $_ }).Count -gt 0
+ }
+ if (-not $matched) {
+ $matchesContext = $false
+ break
+ }
+ }
+ if (-not $matchesContext -or ($ExcludeConditional -and $unknown.Count)) {
+ continue
+ }
+ $null = Resolve-KnowledgePath -Root $BCQualityRoot -Path $path
+
+ $candidate = [ordered]@{
+ path = $path
+ layer = $row.layer
+ keywords = $row.keywords
+ title = $row.title
+ description = $row.description
+ }
+ foreach ($field in $defaults.Keys) {
+ if (($row[$field] -join "`0") -cne ($defaults[$field] -join "`0")) {
+ $candidate[$field] = $row[$field]
+ }
+ }
+ $candidate.applicability = if ($unknown.Count) { 'conditional' } else { 'applicable' }
+ $candidate.unknownDimensions = @($unknown)
+ if ($row.layer -cin $EnabledLayers) {
+ $candidates.Add($candidate)
+ }
+ else {
+ $excluded.Add($candidate)
+ }
+}
+
+$header = [ordered]@{
+ version = 2
+ domain = $Domain
+ context = $context
+ enabledLayers = @($EnabledLayers)
+ indexedLayers = @($indexedLayers)
+ excludeConditional = [bool]$ExcludeConditional
+ defaults = $defaults
+ candidateCount = $candidates.Count
+ excludedByConfigurationCount = $excluded.Count
+}
+
+function Get-CatalogSnapshot {
+ param(
+ [string] $PreparedIndexSha256,
+ [Collections.IDictionary] $Request,
+ [Collections.IDictionary] $Groups
+ )
+
+ $hash = [Security.Cryptography.IncrementalHash]::CreateHash(
+ [Security.Cryptography.HashAlgorithmName]::SHA256
+ )
+ try {
+ foreach ($value in @(
+ $PreparedIndexSha256,
+ (ConvertTo-Json -InputObject $Request -Depth 8 -Compress)
+ )) {
+ $hash.AppendData([Text.Encoding]::UTF8.GetBytes($value))
+ $hash.AppendData([byte[]](10))
+ }
+ foreach ($groupName in $Groups.Keys) {
+ $hash.AppendData([Text.Encoding]::UTF8.GetBytes("[$groupName]"))
+ $hash.AppendData([byte[]](10))
+ foreach ($row in $Groups[$groupName]) {
+ $hash.AppendData([Text.Encoding]::UTF8.GetBytes(
+ (ConvertTo-Json -InputObject $row -Depth 8 -Compress)
+ ))
+ $hash.AppendData([byte[]](10))
+ }
+ }
+ return [Convert]::ToHexString($hash.GetHashAndReset()).ToLowerInvariant()
+ }
+ finally {
+ $hash.Dispose()
+ }
+}
+
+$groups = [ordered]@{
+ candidates = $candidates
+ excludedByConfiguration = $excluded
+}
+$header.snapshot = Get-CatalogSnapshot -PreparedIndexSha256 $indexContent.sha256 -Request $header -Groups $groups
+
+ConvertTo-BoundedPage -Header $header -Groups $groups -Offset $Offset -Snapshot $Snapshot -MaxBytes $MaxBytes
diff --git a/tools/Test-KnowledgeRetrieval.ps1 b/tools/Test-KnowledgeRetrieval.ps1
new file mode 100644
index 0000000..ef65812
--- /dev/null
+++ b/tools/Test-KnowledgeRetrieval.ps1
@@ -0,0 +1,788 @@
+<#
+.SYNOPSIS
+ Validates lossless bounded catalog and exact-body retrieval.
+#>
+#requires -Version 7.2
+[CmdletBinding()]
+param(
+ [string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..'))
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$Root = (Resolve-Path -LiteralPath $Root).Path
+
+$generator = Join-Path $Root 'tools/Build-KnowledgeIndex.ps1'
+$search = Join-Path $Root 'tools/Search-Knowledge.ps1'
+$getArticles = Join-Path $Root 'tools/Get-KnowledgeArticles.ps1'
+$utf8 = [Text.UTF8Encoding]::new($false, $true)
+
+function Assert-True {
+ param([bool] $Condition, [string] $Message)
+ if (-not $Condition) {
+ throw "Assertion failed: $Message"
+ }
+}
+
+function Assert-Equal {
+ param($Actual, $Expected, [string] $Message)
+ if ($Actual -cne $Expected) {
+ throw "Assertion failed: $Message. Expected '$Expected', got '$Actual'."
+ }
+}
+
+function Assert-Sequence {
+ param($Actual, $Expected, [string] $Message)
+ $actualJson = ConvertTo-Json -InputObject @($Actual) -Compress
+ $expectedJson = ConvertTo-Json -InputObject @($Expected) -Compress
+ if ($actualJson -cne $expectedJson) {
+ throw "Assertion failed: $Message. Expected $expectedJson, got $actualJson."
+ }
+}
+
+function Assert-Throws {
+ param([scriptblock] $Action, [string] $Pattern, [string] $Message)
+ try {
+ & $Action
+ }
+ catch {
+ if ($_.Exception.Message -notmatch $Pattern) {
+ throw "Assertion failed: $Message. Wrong error: $($_.Exception.Message)"
+ }
+ return
+ }
+ throw "Assertion failed: $Message. No error was thrown."
+}
+
+function Get-OutputByteCount {
+ param([string] $Text)
+ return [Text.Encoding]::UTF8.GetByteCount($Text) +
+ [Text.Encoding]::UTF8.GetByteCount([Environment]::NewLine)
+}
+
+function Invoke-CatalogPages {
+ param(
+ [hashtable] $Arguments,
+ [int] $MaxBytes = 4096
+ )
+
+ $allCandidates = [Collections.Generic.List[object]]::new()
+ $allExcluded = [Collections.Generic.List[object]]::new()
+ $offset = 0
+ $snapshot = ''
+ $shared = ''
+ $pageCount = 0
+ $lastPage = $null
+ do {
+ $pageArguments = @{} + $Arguments
+ $pageArguments.MaxBytes = $MaxBytes
+ $pageArguments.Offset = $offset
+ if ($snapshot) {
+ $pageArguments.Snapshot = $snapshot
+ }
+ $raw = & $search @pageArguments
+ Assert-True ($raw -is [string]) 'catalog helper emitted exactly one JSON string'
+ Assert-True ((Get-OutputByteCount -Text $raw) -le $MaxBytes) 'catalog page includes its newline in MaxBytes'
+ $page = $raw | ConvertFrom-Json
+ $pageCount++
+ Assert-True ($pageCount -le 1000) 'catalog continuation terminates'
+ Assert-Equal $page.offset $offset 'catalog offset is exact'
+ Assert-Equal $page.returnedCount (@($page.candidates).Count + @($page.excludedByConfiguration).Count) 'page returnedCount matches rows'
+ Assert-Equal $page.remainingCount ($page.totalCount - $offset - $page.returnedCount) 'page remainingCount is exact'
+
+ $currentShared = [ordered]@{
+ version = $page.version
+ domain = $page.domain
+ context = $page.context
+ enabledLayers = $page.enabledLayers
+ indexedLayers = $page.indexedLayers
+ excludeConditional = $page.excludeConditional
+ defaults = $page.defaults
+ candidateCount = $page.candidateCount
+ excludedByConfigurationCount = $page.excludedByConfigurationCount
+ snapshot = $page.snapshot
+ totalCount = $page.totalCount
+ } | ConvertTo-Json -Depth 8 -Compress
+ if (-not $shared) {
+ $shared = $currentShared
+ $snapshot = $page.snapshot
+ }
+ else {
+ Assert-Equal $currentShared $shared 'catalog pages repeat shared context, defaults, totals, and snapshot'
+ }
+
+ foreach ($row in @($page.candidates)) {
+ $allCandidates.Add($row)
+ }
+ foreach ($row in @($page.excludedByConfiguration)) {
+ $allExcluded.Add($row)
+ }
+ if (-not $page.complete) {
+ Assert-True ($null -ne $page.continuation) 'incomplete page has continuation'
+ Assert-Equal $page.continuation.snapshot $snapshot 'continuation is snapshot-bound'
+ Assert-True ($page.continuation.offset -gt $offset) 'continuation makes progress'
+ $offset = $page.continuation.offset
+ }
+ $lastPage = $page
+ } while (-not $page.complete)
+
+ Assert-True ($null -eq $lastPage.continuation) 'final page has no continuation'
+ Assert-Equal $allCandidates.Count $lastPage.candidateCount 'candidate total survives paging'
+ Assert-Equal $allExcluded.Count $lastPage.excludedByConfigurationCount 'excluded total survives paging'
+ return [pscustomobject]@{
+ candidates = @($allCandidates)
+ excluded = @($allExcluded)
+ pages = $pageCount
+ snapshot = $snapshot
+ lastPage = $lastPage
+ }
+}
+
+function Test-BodyRoundTrip {
+ param(
+ [string[]] $Paths,
+ [string] $IndexPath,
+ [switch] $Samples
+ )
+
+ $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ for ($start = 0; $start -lt $Paths.Count; $start += 8) {
+ $end = [Math]::Min($start + 7, $Paths.Count - 1)
+ $remaining = @($Paths[$start..$end])
+ $snapshot = ''
+ do {
+ $arguments = @{
+ BCQualityRoot = $Root
+ IndexPath = $IndexPath
+ Paths = $remaining
+ MaxArticles = 8
+ MaxBytes = 16000
+ }
+ if ($Samples) {
+ $arguments.Samples = $true
+ }
+ if ($snapshot) {
+ $arguments.Snapshot = $snapshot
+ }
+ $raw = & $getArticles @arguments
+ Assert-True ($raw -is [string]) 'article helper emitted exactly one JSON string'
+ Assert-True ((Get-OutputByteCount -Text $raw) -le 16000) 'article batch includes its newline in MaxBytes'
+ $batch = $raw | ConvertFrom-Json
+ Assert-True ($batch.returnedCount -gt 0) 'article batching makes progress'
+ Assert-Equal $batch.returnedCount @($batch.articles).Count 'article returnedCount matches rows'
+ Assert-Equal $batch.complete (@($batch.remainingPaths).Count -eq 0) 'article completion matches remaining paths'
+ if ($batch.complete) {
+ Assert-True ($null -eq $batch.continuation) 'complete article batch has no continuation'
+ }
+ else {
+ Assert-True ($batch.continuation.snapshot -match '^[a-f0-9]{64}$') 'article continuation is snapshot-bound'
+ }
+
+ foreach ($article in @($batch.articles)) {
+ Assert-True ($seen.Add($article.path)) "body returned once: $($article.path)"
+ $fullPath = Join-Path $Root ($article.path.Replace('/', [IO.Path]::DirectorySeparatorChar))
+ $bytes = [IO.File]::ReadAllBytes($fullPath)
+ $text = $utf8.GetString($bytes)
+ $hash = [Convert]::ToHexString(
+ [Security.Cryptography.SHA256]::HashData($bytes)
+ ).ToLowerInvariant()
+ Assert-Equal $article.bytes $bytes.Length "byte count round-trips: $($article.path)"
+ Assert-Equal $article.sha256 $hash "SHA-256 round-trips: $($article.path)"
+ Assert-Equal $article.body $text "body round-trips: $($article.path)"
+ }
+ $remaining = @($batch.remainingPaths)
+ $snapshot = if ($batch.complete) { '' } else { $batch.continuation.snapshot }
+ } while ($remaining.Count)
+ }
+ Assert-Equal $seen.Count $Paths.Count 'every requested body round-trips without loss'
+}
+
+function New-NeutralArticle {
+ param(
+ [string] $FixtureRoot,
+ [string] $Layer,
+ [string] $Slug,
+ [string] $Version = 'all',
+ [string] $Technology = 'al',
+ [string] $Country = 'w1',
+ [string] $Area = 'all',
+ [string] $Title = 'Neutral retrieval example',
+ [string] $Description = 'Neutral retrieval metadata for deterministic tests.'
+ )
+
+ $directory = Join-Path $FixtureRoot "$Layer\knowledge\neutral"
+ New-Item -ItemType Directory -Force -Path $directory | Out-Null
+ $content = @"
+---
+bc-version: [$Version]
+domain: neutral
+keywords: [neutral, retrieval, deterministic]
+technologies: [$Technology]
+countries: [$Country]
+application-area: [$Area]
+---
+
+# $Title
+
+## Description
+
+$Description
+"@
+ Set-Content -LiteralPath (Join-Path $directory "$Slug.md") -Value $content -Encoding utf8NoBOM
+}
+
+function Test-InvalidSourceIndexing {
+ param(
+ [string] $FixtureRoot,
+ [string] $Field,
+ [string] $ValidValue,
+ [string] $InvalidValue
+ )
+
+ New-NeutralArticle -FixtureRoot $FixtureRoot -Layer microsoft -Slug valid-source
+ New-NeutralArticle -FixtureRoot $FixtureRoot -Layer community -Slug invalid-source
+ $articlePath = Join-Path $FixtureRoot 'community\knowledge\neutral\invalid-source.md'
+ $text = [IO.File]::ReadAllText($articlePath, $utf8)
+ $text = $text.Replace("$Field`: $ValidValue", "$Field`: $InvalidValue")
+ [IO.File]::WriteAllText($articlePath, $text, $utf8)
+
+ $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("$Field-index.json")
+ $generation = @(& $generator -BCQualityRoot $FixtureRoot -IndexPath $indexPath 3>&1)
+ $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] })
+ Assert-Equal $warnings.Count 1 "scalar $Field source emits one omission warning"
+ Assert-True (
+ $warnings[0].Message -match
+ "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-source\.md': frontmatter field '$([regex]::Escape($Field))' must use non-empty bracket-array syntax\."
+ ) "scalar $Field warning identifies the exact path and reason"
+ $prepared = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ Assert-Equal $prepared.articleCount 1 "scalar $Field source is omitted while its valid sibling is indexed"
+ Assert-Sequence $prepared.articles.path @('microsoft/knowledge/neutral/valid-source.md') "scalar $Field index contains only the valid sibling"
+ $catalog = & $search -BCQualityRoot $FixtureRoot -IndexPath $indexPath -Domain neutral |
+ ConvertFrom-Json
+ Assert-Sequence $catalog.candidates.path @('microsoft/knowledge/neutral/valid-source.md') "scalar $Field catalog retrieves the valid sibling"
+ $valid = & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath `
+ -Paths 'microsoft/knowledge/neutral/valid-source.md' |
+ ConvertFrom-Json
+ Assert-True $valid.complete "scalar $Field valid sibling body retrieves completely"
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath `
+ -Paths 'community/knowledge/neutral/invalid-source.md'
+ } 'Selected article is absent from the prepared index' "scalar $Field omitted source cannot be retrieved"
+}
+
+function Test-InvalidSemanticIndexing {
+ param(
+ [string] $FixtureRoot,
+ [string] $CaseName,
+ [string] $Field,
+ [string] $ValidValue,
+ [string] $InvalidValue,
+ [string] $ExpectedReason
+ )
+
+ New-NeutralArticle -FixtureRoot $FixtureRoot -Layer microsoft -Slug valid-source
+ New-NeutralArticle -FixtureRoot $FixtureRoot -Layer community -Slug invalid-source
+ $articlePath = Join-Path $FixtureRoot 'community\knowledge\neutral\invalid-source.md'
+ $text = [IO.File]::ReadAllText($articlePath, $utf8)
+ $text = $text.Replace("$Field`: $ValidValue", "$Field`: $InvalidValue")
+ [IO.File]::WriteAllText($articlePath, $text, $utf8)
+
+ $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("$CaseName-index.json")
+ $generation = @(& $generator -BCQualityRoot $FixtureRoot -IndexPath $indexPath 3>&1)
+ $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] })
+ Assert-Equal $warnings.Count 1 "$CaseName emits one omission warning"
+ Assert-Equal $warnings[0].Message "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-source.md': $ExpectedReason." "$CaseName warning identifies exact path and reason"
+
+ $prepared = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ Assert-Equal $prepared.articleCount 1 "$CaseName omits invalid source and retains valid sibling"
+ Assert-Sequence $prepared.articles.path @('microsoft/knowledge/neutral/valid-source.md') "$CaseName index contains only valid sibling"
+ Assert-True ($prepared.sourceSnapshot -match '^[a-f0-9]{64}$') "$CaseName source snapshot remains valid"
+ $validRow = $prepared.articles[0]
+ $manifest = @(
+ [ordered]@{ path = $validRow.path; sha256 = $validRow.sourceSha256 }
+ )
+ $manifestBytes = [Text.Encoding]::UTF8.GetBytes(
+ (ConvertTo-Json -InputObject $manifest -Depth 8 -Compress)
+ )
+ $expectedSnapshot = [Convert]::ToHexString(
+ [Security.Cryptography.SHA256]::HashData($manifestBytes)
+ ).ToLowerInvariant()
+ Assert-Equal $prepared.sourceSnapshot $expectedSnapshot "$CaseName source snapshot covers only retained rows"
+
+ $catalog = & $search -BCQualityRoot $FixtureRoot -IndexPath $indexPath -Domain neutral |
+ ConvertFrom-Json
+ Assert-Sequence $catalog.candidates.path @('microsoft/knowledge/neutral/valid-source.md') "$CaseName catalog retains valid sibling"
+ $valid = & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath `
+ -Paths 'microsoft/knowledge/neutral/valid-source.md' |
+ ConvertFrom-Json
+ Assert-True $valid.complete "$CaseName valid sibling body retrieves"
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath `
+ -Paths 'community/knowledge/neutral/invalid-source.md'
+ } 'Selected article is absent from the prepared index' "$CaseName invalid source cannot be retrieved"
+}
+
+function Test-InvalidEnabledLayers {
+ param(
+ [string] $FixtureRoot,
+ [string] $CaseName,
+ $Layers,
+ [string] $ExpectedPattern
+ )
+
+ $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("layers-$CaseName.json")
+ $arguments = @{
+ BCQualityRoot = $FixtureRoot
+ IndexPath = $indexPath
+ EnabledLayers = $Layers
+ }
+ Assert-Throws {
+ & $generator @arguments
+ } $ExpectedPattern "$CaseName EnabledLayers fails"
+ Assert-True (-not (Test-Path -LiteralPath $indexPath)) "$CaseName fails before index creation"
+}
+
+$tmp = Join-Path ([IO.Path]::GetTempPath()) ("bcquality_retrieval_" + [guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Force -Path $tmp | Out-Null
+try {
+ $indexPath = Join-Path $tmp 'knowledge-index.json'
+ & $generator -BCQualityRoot $Root -IndexPath $indexPath | Out-Null
+ $index = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $diskArticlePaths = @(
+ foreach ($layer in 'microsoft', 'community', 'custom') {
+ $knowledge = Join-Path $Root "$layer\knowledge"
+ if (Test-Path -LiteralPath $knowledge) {
+ Get-ChildItem -LiteralPath $knowledge -Recurse -File -Filter '*.md' |
+ ForEach-Object {
+ [IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/')
+ }
+ }
+ }
+ ) | Sort-Object
+ Assert-Equal $index.articleCount $diskArticlePaths.Count 'index covers every current article'
+ Assert-True ($index.sourceSnapshot -match '^[a-f0-9]{64}$') 'index carries an exact source snapshot'
+
+ $allCatalogRows = [Collections.Generic.List[object]]::new()
+ $domains = @($index.articles.domain | Sort-Object -Unique)
+ foreach ($domain in $domains) {
+ $catalog = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $Root
+ IndexPath = $indexPath
+ Domain = $domain
+ }
+ Assert-Equal $catalog.excluded.Count 0 "all layers enabled for $domain"
+ foreach ($row in $catalog.candidates) {
+ $allCatalogRows.Add($row)
+ }
+ }
+
+ $expectedRows = @($index.articles | Sort-Object path)
+ $actualRows = @($allCatalogRows | Sort-Object path)
+ Assert-Equal $actualRows.Count $expectedRows.Count 'paged union has no top-k or query-based loss'
+ Assert-Sequence ($actualRows.path) ($expectedRows.path) 'paged union equals all READ-filtered candidates'
+ Assert-Equal @($actualRows.path | Sort-Object -Unique).Count $actualRows.Count 'catalog does not deduplicate distinct paths'
+
+ $defaults = [ordered]@{
+ 'bc-version' = @('all')
+ technologies = @('al')
+ countries = @('w1')
+ 'application-area' = @('all')
+ }
+ for ($i = 0; $i -lt $actualRows.Count; $i++) {
+ $actual = $actualRows[$i]
+ $expected = $expectedRows[$i]
+ Assert-Equal $actual.path $expected.path 'catalog preserves exact path'
+ Assert-Equal $actual.layer $expected.layer 'catalog preserves layer'
+ Assert-Sequence $actual.keywords $expected.keywords 'catalog preserves full keywords'
+ Assert-Equal $actual.title $expected.title 'catalog preserves title'
+ Assert-Equal $actual.description $expected.description 'catalog preserves one-line description'
+
+ $unknown = [Collections.Generic.List[string]]::new()
+ foreach ($field in $defaults.Keys) {
+ $expectedValues = @($expected.$field)
+ $sentinel = switch ($field) {
+ 'bc-version' { 'all' }
+ 'countries' { 'w1' }
+ 'application-area' { 'all' }
+ default { '' }
+ }
+ if (-not $sentinel -or $expectedValues -notcontains $sentinel) {
+ $unknown.Add($field)
+ }
+ $hasField = $actual.PSObject.Properties.Name -ccontains $field
+ if (($expectedValues -join "`0") -ceq (@($defaults[$field]) -join "`0")) {
+ Assert-True (-not $hasField) "default field is inherited from page: $field"
+ }
+ else {
+ Assert-True $hasField "non-default field survives paging: $field"
+ Assert-Sequence $actual.$field $expectedValues "non-default field is exact: $field"
+ }
+ }
+ Assert-Equal $actual.applicability ($(if ($unknown.Count) { 'conditional' } else { 'applicable' })) 'applicability verdict is explicit'
+ Assert-Sequence $actual.unknownDimensions @($unknown) 'unknown dimensions are explicit'
+ }
+
+ $performanceFirst = & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -MaxBytes 4096 |
+ ConvertFrom-Json
+ Assert-True (-not $performanceFirst.complete) 'large domain produces deterministic continuation'
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -MaxBytes 4096 -Offset $performanceFirst.continuation.offset
+ } 'Continuation requires Snapshot' 'continuation without snapshot fails'
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -Offset $performanceFirst.totalCount
+ } 'Invalid Offset' 'offset at total fails'
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -Offset 1 -Snapshot ('0' * 64)
+ } 'Snapshot changed' 'wrong snapshot fails'
+
+ $changedRawIndex = Join-Path $tmp 'changed-raw-index.json'
+ $changedRaw = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $changedRaw.generatedAt = [DateTimeOffset]::UtcNow.ToString('O')
+ $changedRaw | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $changedRawIndex -Encoding utf8NoBOM -NoNewline
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $changedRawIndex -Domain performance `
+ -MaxBytes 4096 -Offset $performanceFirst.continuation.offset `
+ -Snapshot $performanceFirst.continuation.snapshot
+ } 'Snapshot changed' 'continuation is bound to the exact prepared index bytes'
+
+ $malformedIndex = Join-Path $tmp 'malformed.json'
+ Set-Content -LiteralPath $malformedIndex -Value '{not-json' -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $malformedIndex -Domain performance
+ } 'Malformed knowledge index JSON' 'malformed JSON fails'
+
+ $unsafeIndex = Join-Path $tmp 'unsafe.json'
+ $unsafe = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $unsafe.articles[0].path = '../outside.md'
+ $unsafe | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $unsafeIndex -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $unsafeIndex -Domain performance
+ } 'Invalid knowledge path' 'unsafe indexed path fails'
+
+ $invalidRowIndex = Join-Path $tmp 'invalid-row.json'
+ $invalidRow = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $invalidRow.articles[0].keywords = @()
+ $invalidRow | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $invalidRowIndex -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $invalidRowIndex -Domain performance
+ } 'Malformed knowledge index row' 'malformed index row fails'
+
+ $semanticCorruptIndex = Join-Path $tmp 'semantic-corrupt-row.json'
+ $semanticCorrupt = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $semanticCorrupt.articles[0].countries = @('usa')
+ $semanticCorrupt | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $semanticCorruptIndex -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $semanticCorruptIndex -Domain performance
+ } 'Malformed knowledge index row.*invalid countries' 'search rejects semantically invalid external index rows'
+
+ $corruptSemanticCases = @(
+ @{ name = 'uppercase-all'; field = 'bc-version'; value = @('ALL'); reason = 'invalid bc-version' },
+ @{ name = 'uppercase-w1'; field = 'countries'; value = @('W1'); reason = 'invalid countries' },
+ @{ name = 'zero-open-range'; field = 'bc-version'; value = @('"0.."'); reason = 'invalid bc-version range bound' },
+ @{ name = 'zero-closed-range'; field = 'bc-version'; value = @('"0..0"'); reason = 'invalid bc-version range bound' }
+ )
+ foreach ($case in $corruptSemanticCases) {
+ $corruptPath = Join-Path $tmp ("corrupt-$($case.name).json")
+ $corrupt = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $corrupt.articles[0].PSObject.Properties[$case.field].Value = $case.value
+ $corrupt | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $corruptPath -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $corruptPath -Domain performance
+ } "Malformed knowledge index row.*$([regex]::Escape($case.reason))" "search rejects $($case.name) in an external index"
+ }
+
+ $invalidUtf8Root = Join-Path $tmp 'invalid-utf8-source'
+ New-NeutralArticle -FixtureRoot $invalidUtf8Root -Layer microsoft -Slug valid-catalog
+ New-NeutralArticle -FixtureRoot $invalidUtf8Root -Layer community -Slug invalid-utf8-source
+ $invalidUtf8Article = Join-Path $invalidUtf8Root 'community\knowledge\neutral\invalid-utf8-source.md'
+ $validBytes = [IO.File]::ReadAllBytes($invalidUtf8Article)
+ [IO.File]::WriteAllBytes($invalidUtf8Article, [byte[]]@($validBytes + @(0xc3, 0x28)))
+ $invalidUtf8Index = Join-Path $tmp 'invalid-utf8-index.json'
+ $generation = @(& $generator -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index 3>&1)
+ $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] })
+ Assert-Equal $warnings.Count 1 'malformed UTF-8 source emits one omission warning'
+ Assert-Equal $warnings[0].Message "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-utf8-source.md': invalid UTF-8." 'malformed UTF-8 warning identifies the exact path and reason'
+ $invalidUtf8Prepared = Get-Content -LiteralPath $invalidUtf8Index -Raw -Encoding utf8 |
+ ConvertFrom-Json
+ Assert-Equal $invalidUtf8Prepared.articleCount 1 'malformed UTF-8 source is omitted while its valid sibling is indexed'
+ Assert-Sequence $invalidUtf8Prepared.articles.path @('microsoft/knowledge/neutral/valid-catalog.md') 'malformed UTF-8 index contains only the valid sibling'
+ $validCatalog = & $search -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index -Domain neutral |
+ ConvertFrom-Json
+ Assert-Sequence $validCatalog.candidates.path @('microsoft/knowledge/neutral/valid-catalog.md') 'catalog retrieves the valid sibling after malformed UTF-8 omission'
+ $validBody = & $getArticles -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index `
+ -Paths 'microsoft/knowledge/neutral/valid-catalog.md' |
+ ConvertFrom-Json
+ Assert-True $validBody.complete 'valid sibling body retrieves after malformed UTF-8 omission'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index `
+ -Paths 'community/knowledge/neutral/invalid-utf8-source.md'
+ } 'Selected article is absent from the prepared index' 'omitted malformed UTF-8 source cannot be retrieved'
+
+ $scalarCases = @(
+ @{ field = 'bc-version'; valid = '[all]'; invalid = 'all' },
+ @{ field = 'keywords'; valid = '[neutral, retrieval, deterministic]'; invalid = 'neutral' },
+ @{ field = 'technologies'; valid = '[al]'; invalid = 'al' },
+ @{ field = 'countries'; valid = '[w1]'; invalid = 'w1' },
+ @{ field = 'application-area'; valid = '[all]'; invalid = 'all' }
+ )
+ foreach ($case in $scalarCases) {
+ Test-InvalidSourceIndexing -FixtureRoot (Join-Path $tmp "scalar-$($case.field)") `
+ -Field $case.field -ValidValue $case.valid -InvalidValue $case.invalid
+ }
+
+ $semanticCases = @(
+ @{ name = 'mixed-version-sentinel'; field = 'bc-version'; valid = '[all]'; invalid = '[all, 27]'; reason = 'mixed bc-version sentinel' },
+ @{ name = 'invalid-country'; field = 'countries'; valid = '[w1]'; invalid = '[usa]'; reason = 'invalid countries' },
+ @{ name = 'descending-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["28..27"]'; reason = 'descending bc-version range' },
+ @{ name = 'malformed-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '[twenty-seven]'; reason = 'invalid bc-version' },
+ @{ name = 'malformed-keyword'; field = 'keywords'; valid = '[neutral, retrieval, deterministic]'; invalid = '[neutral, Bad_Token, deterministic]'; reason = 'invalid keywords' },
+ @{ name = 'malformed-technology'; field = 'technologies'; valid = '[al]'; invalid = '[AL]'; reason = 'invalid technologies' },
+ @{ name = 'malformed-application-area'; field = 'application-area'; valid = '[all]'; invalid = '[finance_]'; reason = 'invalid application-area' },
+ @{ name = 'uppercase-version-sentinel'; field = 'bc-version'; valid = '[all]'; invalid = '[ALL]'; reason = 'invalid bc-version' },
+ @{ name = 'uppercase-country-sentinel'; field = 'countries'; valid = '[w1]'; invalid = '[W1]'; reason = 'invalid countries' },
+ @{ name = 'zero-open-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["0.."]'; reason = 'invalid bc-version range bound' },
+ @{ name = 'zero-closed-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["0..0"]'; reason = 'invalid bc-version range bound' }
+ )
+ foreach ($case in $semanticCases) {
+ Test-InvalidSemanticIndexing -FixtureRoot (Join-Path $tmp "semantic-$($case.name)") `
+ -CaseName $case.name -Field $case.field -ValidValue $case.valid `
+ -InvalidValue $case.invalid -ExpectedReason $case.reason
+ }
+
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain ('x' * 2000) -MaxBytes 1024
+ } 'Page envelope exceeds' 'oversized page envelope fails'
+
+ $articlePaths = @($index.articles.path | Sort-Object)
+ Assert-Sequence $articlePaths $diskArticlePaths 'exact article path union matches disk'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $Root -IndexPath $indexPath -Paths @($articlePaths[0..8])
+ } 'exceeds MaxArticles=8' 'exact retrieval rejects path batches larger than eight'
+ $samplePaths = @(
+ foreach ($layer in 'microsoft', 'community', 'custom') {
+ $knowledge = Join-Path $Root "$layer\knowledge"
+ if (Test-Path -LiteralPath $knowledge) {
+ Get-ChildItem -LiteralPath $knowledge -Recurse -File |
+ Where-Object Name -Match '\.(good|bad)\.[a-z0-9]+$' |
+ ForEach-Object {
+ [IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/')
+ }
+ }
+ }
+ ) | Sort-Object
+ Test-BodyRoundTrip -Paths $articlePaths -IndexPath $indexPath
+ Test-BodyRoundTrip -Paths $samplePaths -IndexPath $indexPath -Samples
+
+ $fixtureRoot = Join-Path $tmp 'neutral'
+ New-NeutralArticle -FixtureRoot $fixtureRoot -Layer microsoft -Slug default
+ New-NeutralArticle -FixtureRoot $fixtureRoot -Layer community -Slug versioned -Version '"27.."' -Technology javascript -Country dk -Area finance -Title 'Versioned neutral example'
+ New-NeutralArticle -FixtureRoot $fixtureRoot -Layer custom -Slug localized -Version 28 -Technology al -Country de -Area service -Title 'Localized neutral example'
+ $fixtureIndex = Join-Path $tmp 'neutral-index.json'
+ & $generator -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex | Out-Null
+
+ foreach ($case in @(
+ @{ name = 'uppercase'; layers = @('Microsoft'); pattern = 'unique canonical lowercase layer names' },
+ @{ name = 'duplicate'; layers = @('microsoft', 'microsoft'); pattern = 'unique canonical lowercase layer names' },
+ @{ name = 'unknown'; layers = @('partner'); pattern = 'unique canonical lowercase layer names' },
+ @{ name = 'null'; layers = $null; pattern = 'must be an array' }
+ )) {
+ Test-InvalidEnabledLayers -FixtureRoot $fixtureRoot -CaseName $case.name `
+ -Layers $case.layers -ExpectedPattern $case.pattern
+ }
+ $subsetIndex = Join-Path $tmp 'community-only-index.json'
+ & $generator -BCQualityRoot $fixtureRoot -IndexPath $subsetIndex `
+ -EnabledLayers @('community') | Out-Null
+ $subset = & $search -BCQualityRoot $fixtureRoot -IndexPath $subsetIndex `
+ -Domain neutral -EnabledLayers @('community') |
+ ConvertFrom-Json
+ Assert-Equal $subset.candidateCount 1 'valid EnabledLayers subset builds and is consumable'
+ Assert-Sequence $subset.candidates.path @('community/knowledge/neutral/versioned.md') 'valid subset contains only its exact layer'
+
+ $applicable = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $fixtureRoot
+ IndexPath = $fixtureIndex
+ Domain = 'neutral'
+ BCVersion = 28
+ Technologies = @('al', 'javascript')
+ Countries = @('dk', 'de')
+ ApplicationAreas = @('finance', 'service')
+ } -MaxBytes 16000
+ Assert-Equal $applicable.candidates.Count 3 'neutral layer/version rows all survive matching context'
+ Assert-True (@($applicable.candidates | Where-Object applicability -CEQ applicable).Count -eq 3) 'matching rows are applicable'
+ $versioned = $applicable.candidates | Where-Object path -CEQ 'community/knowledge/neutral/versioned.md'
+ Assert-Equal $versioned.layer community 'non-default layer survives'
+ Assert-Sequence $versioned.'bc-version' @('"27.."') 'original version metadata survives'
+ Assert-Equal $versioned.applicability applicable 'lowercase sentinels and positive open range remain applicable'
+ Assert-Sequence $versioned.technologies @('javascript') 'non-default technology survives'
+ Assert-Sequence $versioned.countries @('dk') 'non-default country survives'
+ Assert-Sequence $versioned.'application-area' @('finance') 'non-default application area survives'
+
+ # Metadata validation accepts range bounds wider than Int32, so version
+ # matching must compare as bigint rather than coercing the bound down.
+ $wideRoot = Join-Path $tmp 'wide-version'
+ New-NeutralArticle -FixtureRoot $wideRoot -Layer microsoft -Slug wide-closed -Version '"1..99999999999"'
+ New-NeutralArticle -FixtureRoot $wideRoot -Layer microsoft -Slug wide-open -Version '"99999999999.."'
+ $wideIndex = Join-Path $tmp 'wide-version-index.json'
+ & $generator -BCQualityRoot $wideRoot -IndexPath $wideIndex | Out-Null
+ $wide = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $wideRoot
+ IndexPath = $wideIndex
+ Domain = 'neutral'
+ BCVersion = 28
+ } -MaxBytes 16000
+ Assert-Sequence $wide.candidates.path @('microsoft/knowledge/neutral/wide-closed.md') 'bc-version bounds beyond Int32 compare without overflow'
+
+ $conditional = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $fixtureRoot
+ IndexPath = $fixtureIndex
+ Domain = 'neutral'
+ BCVersion = 28
+ Technologies = @('al', 'javascript')
+ } -MaxBytes 16000
+ $conditionalVersioned = $conditional.candidates |
+ Where-Object path -CEQ 'community/knowledge/neutral/versioned.md'
+ Assert-Equal $conditionalVersioned.applicability conditional 'unknown context produces conditional verdict'
+ Assert-Sequence $conditionalVersioned.unknownDimensions @('countries', 'application-area') 'unknown dimensions survive'
+
+ $layerFiltered = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $fixtureRoot
+ IndexPath = $fixtureIndex
+ Domain = 'neutral'
+ EnabledLayers = @('microsoft')
+ } -MaxBytes 16000
+ Assert-Equal $layerFiltered.candidates.Count 1 'enabled layer remains a candidate'
+ Assert-Equal $layerFiltered.excluded.Count 2 'disabled layers remain explicit'
+ Assert-Sequence ($layerFiltered.excluded.layer | Sort-Object) @('community', 'custom') 'excluded rows preserve layer'
+
+ $oldSnapshot = $conditional.snapshot
+ Add-Content -LiteralPath (Join-Path $fixtureRoot 'community\knowledge\neutral\versioned.md') -Value ' ' -Encoding utf8NoBOM
+ $preparedCatalog = & $search -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex -Domain neutral |
+ ConvertFrom-Json
+ Assert-Equal $preparedCatalog.candidateCount 3 'catalog uses the prepared index without rehashing article bodies'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex `
+ -Paths 'community/knowledge/neutral/versioned.md'
+ } 'Selected article hash does not match the prepared index' 'exact retrieval detects selected article changes'
+ & $generator -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex | Out-Null
+ Assert-Throws {
+ & $search -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex -Domain neutral -Offset 1 -Snapshot $oldSnapshot
+ } 'Snapshot changed' 'continuation cannot cross rebuilt snapshots'
+
+ $largeRoot = Join-Path $tmp 'large-catalog'
+ New-NeutralArticle -FixtureRoot $largeRoot -Layer microsoft -Slug huge-title -Title ('T' * 3000)
+ $largeIndex = Join-Path $tmp 'large-index.json'
+ & $generator -BCQualityRoot $largeRoot -IndexPath $largeIndex | Out-Null
+ Assert-Throws {
+ & $search -BCQualityRoot $largeRoot -IndexPath $largeIndex -Domain neutral -MaxBytes 1024
+ } 'One complete candidates row|Page envelope exceeds' 'oversized catalog row fails without clipping'
+
+ # The shared pager reports the oversized row's identity for any row shape;
+ # a row without a path must still reach its explicit offset-based failure.
+ . (Join-Path $Root 'tools/Bounded-Results.ps1')
+ $pagerHeader = [ordered]@{ version = 2; snapshot = ('0' * 64) }
+ foreach ($shape in @(
+ @{ name = 'dictionary'; row = [ordered]@{ blob = ('x' * 3000) } },
+ @{ name = 'object'; row = [pscustomobject]@{ blob = ('x' * 3000) } }
+ )) {
+ Assert-Throws {
+ ConvertTo-BoundedPage -Header $pagerHeader `
+ -Groups ([ordered]@{ rows = @($shape.row) }) -MaxBytes 1024
+ } 'One complete rows row plus envelope exceeds MaxBytes=1024 at Offset=0' "oversized pathless $($shape.name) row fails with its offset identity"
+ }
+
+ $bodyRoot = Join-Path $tmp 'body-failures'
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug huge-body -Description ('x' * 3000)
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug broken-link
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug continuation-one -Description ('a' * 300)
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug continuation-two -Description ('b' * 300)
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug invalid-utf8
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug sample-one
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug sample-two
+ Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-one.md') `
+ -Value '[`sample-one.good.al`](sample-one.good.al)' -Encoding utf8NoBOM
+ Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.md') `
+ -Value '[`sample-two.good.al`](sample-two.good.al)' -Encoding utf8NoBOM
+ Set-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-one.good.al') `
+ -Value ('a' * 900) -Encoding utf8NoBOM
+ Set-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.good.al') `
+ -Value ('b' * 900) -Encoding utf8NoBOM
+ $bodyIndex = Join-Path $tmp 'body-index.json'
+ & $generator -BCQualityRoot $bodyRoot -IndexPath $bodyIndex | Out-Null
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths 'microsoft/knowledge/neutral/huge-body.md' -MaxBytes 1024
+ } 'No complete body plus continuation fits' 'oversized body fails without truncation'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex -Paths '../outside.md'
+ } 'Invalid knowledge path' 'unsafe requested path fails'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths 'microsoft/knowledge/neutral/huge-body.md' -EnabledLayers community
+ } 'Layer disabled' 'disabled article layer fails'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex -Paths @(
+ 'microsoft/knowledge/neutral/huge-body.md',
+ 'microsoft/knowledge/neutral/huge-body.md'
+ )
+ } 'Duplicate requested path' 'duplicate exact paths fail'
+
+ $brokenSample = Join-Path $bodyRoot 'microsoft\knowledge\neutral\broken-link.good.al'
+ Set-Content -LiteralPath $brokenSample -Value 'codeunit 1 Neutral { }' -Encoding utf8NoBOM
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths 'microsoft/knowledge/neutral/broken-link.good.al' -Samples
+ } 'Sample is not linked' 'unlinked sample fails'
+
+ $sampleContinuationPaths = @(
+ 'microsoft/knowledge/neutral/sample-one.good.al',
+ 'microsoft/knowledge/neutral/sample-two.good.al'
+ )
+ $firstSamplePage = & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths $sampleContinuationPaths -Samples -MaxBytes 1600 |
+ ConvertFrom-Json
+ Assert-True (-not $firstSamplePage.complete) 'bounded sample batch produces continuation'
+ Assert-Sequence $firstSamplePage.remainingPaths @('microsoft/knowledge/neutral/sample-two.good.al') 'sample continuation preserves pending path'
+ Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.good.al') `
+ -Value 'changed' -Encoding utf8NoBOM
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths @($firstSamplePage.remainingPaths) -Samples `
+ -Snapshot $firstSamplePage.continuation.snapshot
+ } 'Article snapshot changed' 'sample continuation rejects a changed pending sample'
+
+ $continuationPaths = @(
+ 'microsoft/knowledge/neutral/continuation-one.md',
+ 'microsoft/knowledge/neutral/continuation-two.md'
+ )
+ $firstBodyPage = & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths $continuationPaths -MaxBytes 1300 |
+ ConvertFrom-Json
+ Assert-True (-not $firstBodyPage.complete) 'bounded article batch produces continuation'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths @($firstBodyPage.remainingPaths) -Snapshot ('0' * 64)
+ } 'Article snapshot changed' 'wrong article continuation snapshot fails'
+ Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\continuation-two.md') -Value 'changed' -Encoding utf8NoBOM
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths @($firstBodyPage.remainingPaths) -Snapshot $firstBodyPage.continuation.snapshot
+ } 'Selected article hash does not match the prepared index' 'article continuation rejects a changed remaining body'
+
+ $invalidUtf8 = Join-Path $bodyRoot 'microsoft\knowledge\neutral\invalid-utf8.md'
+ $indexedBytes = [IO.File]::ReadAllBytes($invalidUtf8)
+ [IO.File]::WriteAllBytes($invalidUtf8, [byte[]]@($indexedBytes + @(0xc3, 0x28)))
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths 'microsoft/knowledge/neutral/invalid-utf8.md'
+ } 'Knowledge file is not valid strict UTF-8' 'invalid UTF-8 fails'
+
+ Write-Host "Knowledge retrieval check PASSED: $($articlePaths.Count) articles and $($samplePaths.Count) samples round-tripped; catalog union was lossless and bounded." -ForegroundColor Green
+}
+finally {
+ Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
+}
diff --git a/tools/Test-ReviewContract.ps1 b/tools/Test-ReviewContract.ps1
new file mode 100644
index 0000000..1d40058
--- /dev/null
+++ b/tools/Test-ReviewContract.ps1
@@ -0,0 +1,171 @@
+<#
+.SYNOPSIS
+ Validates the bounded leaf-range normalization contract.
+
+.DESCRIPTION
+ BCQuality has no executable findings-report consumer. These assertions keep
+ the normative DO contract, AL coordinator, and standalone runner aligned
+ while exercising the exact normalization predicate against representative
+ safe and ambiguous inputs.
+#>
+[CmdletBinding()]
+param(
+ [string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..'))
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$Root = (Resolve-Path -LiteralPath $Root).Path
+
+function Assert-True {
+ param(
+ [bool] $Condition,
+ [string] $Message
+ )
+
+ if (-not $Condition) {
+ throw "Assertion failed: $Message"
+ }
+}
+
+function Assert-Contains {
+ param(
+ [string] $Text,
+ [string] $Expected,
+ [string] $Message
+ )
+
+ Assert-True $Text.Contains($Expected) $Message
+}
+
+function Test-PositiveInteger {
+ param([object] $Value)
+
+ if (($null -eq $Value) -or ($Value -is [bool]) -or ($Value -isnot [ValueType])) {
+ return $false
+ }
+
+ $number = [double]$Value
+ return [double]::IsFinite($number) -and ($number -gt 0) -and ([math]::Truncate($number) -eq $number)
+}
+
+function Test-RangeNormalizationEligibility {
+ param([pscustomobject] $Finding)
+
+ if ($Finding.PSObject.Properties.Name -contains 'suggested-code') {
+ return $false
+ }
+ if (-not ($Finding.PSObject.Properties.Name -contains 'location')) {
+ return $false
+ }
+ if (-not ($Finding.location.PSObject.Properties.Name -contains 'line')) {
+ return $false
+ }
+ if (-not ($Finding.location.PSObject.Properties.Name -contains 'range')) {
+ return $false
+ }
+
+ $range = $Finding.location.range
+ if (-not ($range.PSObject.Properties.Name -contains 'start-line') -or
+ -not ($range.PSObject.Properties.Name -contains 'end-line')) {
+ return $false
+ }
+
+ $line = $Finding.location.line
+ $startLine = $range.'start-line'
+ $endLine = $range.'end-line'
+ if (-not (Test-PositiveInteger $line) -or
+ -not (Test-PositiveInteger $startLine) -or
+ -not (Test-PositiveInteger $endLine)) {
+ return $false
+ }
+
+ return ($startLine -le $line) -and ($line -le $endLine) -and ($startLine -ne $line)
+}
+
+$transportSentence = 'Capture the exact Task return as the immutable raw audit payload and primary transport.'
+$doContract = Get-Content -LiteralPath (Join-Path $Root 'skills/do.md') -Raw
+$coordinatorContract = Get-Content -LiteralPath (Join-Path $Root 'microsoft/skills/review/al-code-review.md') -Raw
+$runnerContract = Get-Content -LiteralPath (Join-Path $Root 'docs/standalone-runner.md') -Raw
+
+foreach ($surface in @(
+ [pscustomobject]@{ Name = 'DO'; Text = ($doContract -replace '\s+', ' ') }
+ [pscustomobject]@{ Name = 'AL coordinator'; Text = ($coordinatorContract -replace '\s+', ' ') }
+ [pscustomobject]@{ Name = 'standalone runner'; Text = ($runnerContract -replace '\s+', ' ') }
+)) {
+ Assert-Contains $surface.Text $transportSentence "$($surface.Name) preserves exact Task transport wording"
+}
+
+$normalizedDoContract = $doContract -replace '\s+', ' '
+foreach ($expected in @(
+ 'positive integers',
+ 'start-line <= line <= end-line',
+ 'does not contain the `suggested-code` field',
+ 'remove only',
+ 'private run telemetry or artifacts',
+ 'Validate the entire normalized candidate',
+ 'If any other validation defect exists',
+ 'salvage arbitrary individual findings'
+)) {
+ Assert-Contains $normalizedDoContract $expected "DO documents '$expected'"
+}
+
+$cases = @(
+ [pscustomobject]@{
+ Name = 'contained mismatched range without suggested code'
+ Expected = $true
+ Finding = '{"message":"keep me","location":{"file":"src/codeunit.al","line":37,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'aligned range'
+ Expected = $false
+ Finding = '{"location":{"line":37,"range":{"start-line":37,"end-line":38}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'suggested code present'
+ Expected = $false
+ Finding = '{"location":{"line":37,"range":{"start-line":36,"end-line":38}},"suggested-code":""}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'line outside range'
+ Expected = $false
+ Finding = '{"location":{"line":39,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'reversed range'
+ Expected = $false
+ Finding = '{"location":{"line":37,"range":{"start-line":38,"end-line":36}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'zero bound'
+ Expected = $false
+ Finding = '{"location":{"line":1,"range":{"start-line":0,"end-line":2}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'fractional primary line'
+ Expected = $false
+ Finding = '{"location":{"line":37.5,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'missing end line'
+ Expected = $false
+ Finding = '{"location":{"line":37,"range":{"start-line":36}}}' | ConvertFrom-Json
+ }
+)
+
+foreach ($case in $cases) {
+ $actual = Test-RangeNormalizationEligibility $case.Finding
+ Assert-True ($actual -eq $case.Expected) "$($case.Name) eligibility is $($case.Expected)"
+}
+
+$rawFinding = $cases[0].Finding
+$candidateFinding = $rawFinding | ConvertTo-Json -Depth 10 | ConvertFrom-Json
+$candidateFinding.location.PSObject.Properties.Remove('range')
+
+Assert-True ($rawFinding.location.PSObject.Properties.Name -contains 'range') 'raw finding remains unchanged'
+Assert-True (-not ($candidateFinding.location.PSObject.Properties.Name -contains 'range')) 'candidate removes only the optional range'
+Assert-True ($candidateFinding.location.line -eq $rawFinding.location.line) 'candidate preserves the primary line'
+Assert-True ($candidateFinding.message -ceq $rawFinding.message) 'candidate preserves all other finding content'
+
+Write-Output "Review contract validation passed ($($cases.Count) normalization cases)."
diff --git a/tools/Test-ReviewFixtures.ps1 b/tools/Test-ReviewFixtures.ps1
new file mode 100644
index 0000000..8cf019f
--- /dev/null
+++ b/tools/Test-ReviewFixtures.ps1
@@ -0,0 +1,594 @@
+<#
+.SYNOPSIS
+ Validates and prepares the BCQuality AL review evaluation corpus.
+
+.DESCRIPTION
+ CI uses the static validation path to prove every registered AL review leaf
+ has one positive and one clean control, every fixture/reference exists, and
+ the manifest remains internally consistent.
+
+ For an actual model run, -PrepareDirectory copies inputs to neutral names and
+ emits review-request.json without expected answers. After the model writes a
+ result matching evaluation/README.md, -ResultsPath scores exact knowledge-ID
+ recall, clean-control rate, and unexpected findings.
+#>
+[CmdletBinding()]
+param(
+ [string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')),
+ [string] $ManifestPath,
+ [string] $PrepareDirectory,
+ [string] $ResultsPath,
+ [string] $ResultsDirectory
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$Root = (Resolve-Path -LiteralPath $Root).Path
+if ($ResultsPath -and $ResultsDirectory) {
+ throw 'Specify either ResultsPath or ResultsDirectory, not both.'
+}
+if (-not $ManifestPath) {
+ $ManifestPath = Join-Path $Root 'evaluation/review-fixtures.json'
+}
+if (-not (Test-Path -LiteralPath $ManifestPath)) {
+ throw "Review fixture manifest not found: $ManifestPath"
+}
+
+$manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
+$problems = [System.Collections.Generic.List[string]]::new()
+
+function Get-ModelCaseId {
+ param([string] $ManifestId)
+
+ $sha = [System.Security.Cryptography.SHA256]::Create()
+ try {
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($ManifestId)
+ $hash = $sha.ComputeHash($bytes)
+ $token = ([System.BitConverter]::ToString($hash) -replace '-', '').Substring(0, 8).ToLowerInvariant()
+ return "case-$token"
+ } finally {
+ $sha.Dispose()
+ }
+}
+
+function Get-RankedArticles {
+ param(
+ [object[]] $Articles,
+ [string] $CaseText,
+ [int] $Limit = 10
+ )
+
+ if ($Articles.Count -le $Limit) {
+ return @($Articles)
+ }
+
+ $normalized = (($CaseText.ToLowerInvariant() -replace '[^a-z0-9]+', ' ') -replace '\s+', ' ').Trim()
+ $compact = $normalized -replace ' ', ''
+ $ranked = foreach ($article in $Articles) {
+ $score = 0
+ foreach ($keyword in @($article.keywords)) {
+ $keywordText = ([string]$keyword).ToLowerInvariant()
+ $keywordCompact = $keywordText -replace '[^a-z0-9]+', ''
+ if ($keywordCompact -and $compact.Contains($keywordCompact)) {
+ $score += 8
+ }
+ foreach ($part in @($keywordText -split '[^a-z0-9]+')) {
+ if (($part.Length -ge 4) -and ($normalized -match "(^| )$([regex]::Escape($part))( |$)")) {
+ $score += 1
+ }
+ }
+ }
+ $topicText = "$($article.title) $($article.description) $($article.path)".ToLowerInvariant()
+ foreach ($term in @($normalized -split ' ' | Where-Object Length -ge 5 | Sort-Object -Unique)) {
+ if ($topicText.Contains($term)) {
+ $score += 0.25
+ }
+ }
+ [pscustomobject]@{ score = $score; path = [string]$article.path; article = $article }
+ }
+
+ return @(
+ $ranked |
+ Sort-Object @{ Expression = 'score'; Descending = $true }, @{ Expression = 'path'; Descending = $false } |
+ Select-Object -First $Limit |
+ ForEach-Object article
+ )
+}
+
+if ($manifest.version -ne 2) {
+ $problems.Add("Unsupported manifest version: $($manifest.version)") | Out-Null
+}
+if ($manifest.selection -ne 'first-paired-al-article') {
+ $problems.Add("Unsupported selection strategy: $($manifest.selection)") | Out-Null
+}
+if (([double]$manifest.minimumExpectedRecall -lt 0) -or ([double]$manifest.minimumExpectedRecall -gt 1)) {
+ $problems.Add('minimumExpectedRecall must be between 0 and 1.') | Out-Null
+}
+if (([double]$manifest.minimumCleanRate -lt 0) -or ([double]$manifest.minimumCleanRate -gt 1)) {
+ $problems.Add('minimumCleanRate must be between 0 and 1.') | Out-Null
+}
+
+$layers = @(
+ [pscustomobject]@{ Name = 'microsoft'; Rank = 1 }
+ [pscustomobject]@{ Name = 'community'; Rank = 2 }
+ [pscustomobject]@{ Name = 'custom'; Rank = 3 }
+)
+$layerRanks = @{}
+foreach ($layer in $layers) {
+ $layerRanks[[string]$layer.Name] = [int]$layer.Rank
+}
+$leafCandidates = @(
+ foreach ($layer in $layers) {
+ $reviewDirectory = Join-Path $Root "$($layer.Name)/skills/review"
+ if (-not (Test-Path -LiteralPath $reviewDirectory -PathType Container)) {
+ continue
+ }
+ Get-ChildItem -LiteralPath $reviewDirectory -File -Filter 'al-*-review.md' |
+ Where-Object Name -ne 'al-code-review.md' |
+ ForEach-Object {
+ [pscustomobject]@{
+ Domain = $_.BaseName -replace '^al-', '' -replace '-review$', ''
+ Layer = $layer.Name
+ Rank = $layer.Rank
+ RelativePath = [System.IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/')
+ }
+ }
+ }
+)
+$leafSkills = @(
+ $leafCandidates |
+ Group-Object Domain |
+ ForEach-Object { $_.Group | Sort-Object Rank -Descending | Select-Object -First 1 } |
+ Sort-Object Domain
+)
+$leafDomains = @($leafSkills | ForEach-Object Domain)
+$leafByDomain = @{}
+foreach ($leafSkill in $leafSkills) {
+ $leafByDomain[[string]$leafSkill.Domain] = $leafSkill
+}
+
+$overrides = @{}
+if ($manifest.PSObject.Properties.Name -contains 'overrides') {
+ foreach ($property in $manifest.overrides.PSObject.Properties) {
+ $overrides[$property.Name] = $property.Value
+ }
+}
+foreach ($overrideDomain in $overrides.Keys) {
+ if ($leafDomains -notcontains $overrideDomain) {
+ $problems.Add("Override domain '$overrideDomain' has no registered al-$overrideDomain-review leaf.") | Out-Null
+ }
+}
+
+$caseList = [System.Collections.Generic.List[object]]::new()
+foreach ($domain in $leafDomains) {
+ $articleCandidates = @(
+ foreach ($layer in $layers) {
+ $knowledgeDirectory = Join-Path $Root "$($layer.Name)/knowledge/$domain"
+ if (-not (Test-Path -LiteralPath $knowledgeDirectory -PathType Container)) {
+ continue
+ }
+ Get-ChildItem -LiteralPath $knowledgeDirectory -File -Filter '*.md' |
+ Where-Object {
+ (Test-Path -LiteralPath (Join-Path $knowledgeDirectory "$($_.BaseName).good.al") -PathType Leaf) -and
+ (Test-Path -LiteralPath (Join-Path $knowledgeDirectory "$($_.BaseName).bad.al") -PathType Leaf)
+ } |
+ ForEach-Object {
+ [pscustomobject]@{
+ BaseName = $_.BaseName
+ File = $_
+ Rank = $layer.Rank
+ ArticlePath = [System.IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/')
+ }
+ }
+ }
+ )
+ $articles = @(
+ $articleCandidates |
+ Group-Object BaseName |
+ ForEach-Object { $_.Group | Sort-Object Rank -Descending | Select-Object -First 1 } |
+ Sort-Object BaseName
+ )
+ if (-not $articles.Count) {
+ $problems.Add("${domain}: no enabled knowledge layer has an article with both .good.al and .bad.al companion samples.") | Out-Null
+ continue
+ }
+
+ $override = if ($overrides.ContainsKey($domain)) { $overrides[$domain] } else { $null }
+ $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 = @(
+ foreach ($layer in $layers) {
+ $articleFile = Join-Path $Root "$($layer.Name)/knowledge/$domain/$articleName.md"
+ if (Test-Path -LiteralPath $articleFile -PathType Leaf) {
+ $articleFile
+ }
+ }
+ ).Count -gt 0
+ if ($articleExists) {
+ $problems.Add("${domain}: override article does not have both .good.al and .bad.al companion samples: $articleName.md") | Out-Null
+ } else {
+ $problems.Add("${domain}: override article does not exist: $articleName.md") | Out-Null
+ }
+ continue
+ }
+ $selectedArticles.Add($selectedArticle) | 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
+ }
+
+ $context = if ($override -and ($override.PSObject.Properties.Name -contains 'context')) {
+ [string]$override.context
+ } else {
+ $null
+ }
+ 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
+ }
+ }
+}
+$cases = @($caseList)
+
+$seenIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
+foreach ($case in $cases) {
+ $id = [string]$case.id
+ $domain = [string]$case.domain
+ $inputRelativePath = [string]$case.input
+ $expected = @($case.expected)
+
+ if ([string]::IsNullOrWhiteSpace($id)) {
+ $problems.Add('Case with empty id.') | Out-Null
+ } elseif (-not $seenIds.Add($id)) {
+ $problems.Add("Duplicate case id: $id") | Out-Null
+ }
+ if ($leafDomains -notcontains $domain) {
+ $problems.Add("${id}: domain '$domain' has no registered al-$domain-review leaf.") | Out-Null
+ }
+
+ $inputPath = Join-Path $Root $inputRelativePath
+ if (-not (Test-Path -LiteralPath $inputPath -PathType Leaf)) {
+ $problems.Add("${id}: input does not exist: $inputRelativePath") | Out-Null
+ }
+ if ($expected.Count -and $inputRelativePath -notmatch '\.bad\.[^.]+$') {
+ $problems.Add("${id}: positive case must use a .bad sample: $inputRelativePath") | Out-Null
+ }
+ if (-not $expected.Count -and $inputRelativePath -notmatch '\.good\.[^.]+$') {
+ $problems.Add("${id}: clean case must use a .good sample: $inputRelativePath") | Out-Null
+ }
+
+ foreach ($reference in $expected) {
+ $referencePath = Join-Path $Root ([string]$reference)
+ if (-not (Test-Path -LiteralPath $referencePath -PathType Leaf)) {
+ $problems.Add("${id}: referenced article does not exist: $reference") | Out-Null
+ }
+ }
+ if ($expected.Count) {
+ $sampleSlug = ([System.IO.Path]::GetFileName($inputRelativePath) -replace '\.(?:good|bad)\.[^.]+$', '')
+ $primarySlug = [System.IO.Path]::GetFileNameWithoutExtension([string]$expected[0])
+ if ($sampleSlug -ne $primarySlug) {
+ $problems.Add("${id}: primary expected article '$primarySlug' must match sample slug '$sampleSlug'.") | Out-Null
+ }
+ }
+}
+
+foreach ($domain in $leafDomains) {
+ $domainCases = @($cases | Where-Object domain -eq $domain)
+ if (-not @($domainCases | Where-Object { @($_.expected).Count -gt 0 }).Count) {
+ $problems.Add("${domain}: no positive review fixture.") | Out-Null
+ }
+ if (-not @($domainCases | Where-Object { @($_.expected).Count -eq 0 }).Count) {
+ $problems.Add("${domain}: no clean control fixture.") | Out-Null
+ }
+}
+
+if ($problems.Count) {
+ Write-Host "Review fixture validation FAILED ($($problems.Count) problem(s)):" -ForegroundColor Red
+ $problems | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
+ exit 1
+}
+
+if ($PrepareDirectory) {
+ $markerPath = Join-Path $PrepareDirectory '.bcquality-evaluation'
+ if (Test-Path -LiteralPath $PrepareDirectory) {
+ $existing = @(Get-ChildItem -LiteralPath $PrepareDirectory -Force)
+ if ($existing.Count -and -not (Test-Path -LiteralPath $markerPath -PathType Leaf)) {
+ throw "PrepareDirectory is not empty and is not a BCQuality evaluation directory: $PrepareDirectory"
+ }
+ if (Test-Path -LiteralPath $markerPath -PathType Leaf) {
+ Get-ChildItem -LiteralPath $PrepareDirectory -File |
+ Where-Object {
+ ($_.Name -like 'case*.al') -or
+ ($_.Name -eq 'review-request.json') -or
+ ($_.Name -like 'request-*.json') -or
+ ($_.Name -eq 'knowledge-index.json') -or
+ ($_.Name -like 'index-*.json') -or
+ ($_.Name -like 'result-*.json')
+ } |
+ Remove-Item -Force
+ }
+ } else {
+ New-Item -ItemType Directory -Force -Path $PrepareDirectory | Out-Null
+ }
+ Set-Content -LiteralPath $markerPath -Value 'BCQuality generated evaluation directory' -Encoding UTF8
+
+ $fullIndexPath = Join-Path $PrepareDirectory 'knowledge-index.json'
+ & (Join-Path $Root 'tools/Build-KnowledgeIndex.ps1') -BCQualityRoot $Root -IndexPath $fullIndexPath | Out-Null
+ $fullIndex = Get-Content -LiteralPath $fullIndexPath -Raw | ConvertFrom-Json
+
+ $requestCases = [System.Collections.Generic.List[object]]::new()
+ $requestCasesByDomain = @{}
+ $manifestCaseByModelId = @{}
+ foreach ($case in $cases) {
+ $extension = [System.IO.Path]::GetExtension([string]$case.input)
+ $modelId = Get-ModelCaseId -ManifestId ([string]$case.id)
+ $neutralName = "$modelId$extension"
+ $sourceText = Get-Content -LiteralPath (Join-Path $Root ([string]$case.input)) -Raw
+ # Companion samples are human-facing and often label objects/comments as
+ # Good, Bad, or Anti-pattern. Strip full-line comments and neutralize those
+ # object-name tokens so model-facing fixtures do not reveal the expected
+ # outcome while preserving executable AL structure and references.
+ $neutralText = [regex]::Replace($sourceText, '(?m)^\s*//.*(?:\r?\n|$)', '')
+ $neutralText = [regex]::Replace($neutralText, '\b(?:Good|Bad)\b', 'Eval')
+ Set-Content -LiteralPath (Join-Path $PrepareDirectory $neutralName) -Value $neutralText -Encoding UTF8
+ $requestCase = [pscustomobject]@{ id = $modelId; file = $neutralName }
+ if ($case.PSObject.Properties.Name -contains 'context') {
+ $requestCase | Add-Member -NotePropertyName context -NotePropertyValue ([string]$case.context)
+ }
+ $requestCases.Add($requestCase) | Out-Null
+ $manifestCaseByModelId[$modelId] = $case
+ $domain = [string]$case.domain
+ if (-not $requestCasesByDomain.ContainsKey($domain)) {
+ $requestCasesByDomain[$domain] = [System.Collections.Generic.List[object]]::new()
+ }
+ $requestCasesByDomain[$domain].Add($requestCase) | Out-Null
+ }
+ $resultSchema = [pscustomobject]@{
+ cases = @([pscustomobject]@{
+ id = 'case-id'
+ findings = @([pscustomobject]@{ id = 'repo-relative knowledge article path' })
+ })
+ }
+ [pscustomobject]@{
+ protocol = 'Run BCQuality al-code-review over all files as one PR; return findings per case. Copy every knowledge-backed id from knowledge-index.json.'
+ knowledgeIndex = 'knowledge-index.json'
+ resultSchema = $resultSchema
+ cases = @($requestCases)
+ } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $PrepareDirectory 'review-request.json') -Encoding UTF8
+
+ foreach ($domain in $leafDomains) {
+ $domainArticles = @(
+ $fullIndex.articles |
+ Where-Object domain -eq $domain |
+ Sort-Object @{ Expression = { $layerRanks[[string]$_.layer] }; Descending = $true }, path |
+ Group-Object { [System.IO.Path]::GetFileName([string]$_.path) } |
+ ForEach-Object { $_.Group | Select-Object -First 1 } |
+ Sort-Object path
+ )
+ $domainIndexName = "index-$domain.json"
+ $leafPath = [string]$leafByDomain[$domain].RelativePath
+ $leafFullText = Get-Content -LiteralPath (Join-Path $Root $leafPath) -Raw
+ $leafInstructions = @($leafFullText -split '(?m)^## Output\s*\r?\n', 2)[0]
+ $leafInstructions += "`n## Output`nReturn only the request's resultSchema."
+ [pscustomobject]@{
+ version = $fullIndex.version
+ domain = $domain
+ articleCount = $domainArticles.Count
+ articles = $domainArticles
+ } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $PrepareDirectory $domainIndexName) -Encoding UTF8
+
+ [pscustomobject]@{
+ protocol = "Run only $leafPath over these files. Follow leafInstructions exactly, use only the supplied candidate article rows, open matching articles in full, and copy every finding id verbatim from candidateArticles[].path."
+ skill = $leafPath
+ leafInstructions = $leafInstructions
+ knowledgeIndex = $domainIndexName
+ candidateArticles = $domainArticles
+ resultSchema = $resultSchema
+ cases = @($requestCasesByDomain[$domain])
+ } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $PrepareDirectory "request-$domain.json") -Encoding UTF8
+
+ foreach ($requestCase in @($requestCasesByDomain[$domain])) {
+ $caseText = Get-Content -LiteralPath (Join-Path $PrepareDirectory ([string]$requestCase.file)) -Raw
+ if ($requestCase.PSObject.Properties.Name -contains 'context') {
+ $caseText += " $([string]$requestCase.context)"
+ }
+ $rankedArticles = @(Get-RankedArticles -Articles $domainArticles -CaseText $caseText)
+ $manifestCase = $manifestCaseByModelId[[string]$requestCase.id]
+ $selectedArticlePath = ([string]$manifestCase.input) -replace '\.(?:good|bad)\.al$', '.md'
+ $rankedPaths = @($rankedArticles | ForEach-Object { [string]$_.path })
+ if ($rankedPaths -notcontains $selectedArticlePath) {
+ throw "$($manifestCase.id): deterministic ranking omitted selected article '$selectedArticlePath'. Improve its retrieval metadata or choose an exceptional override article."
+ }
+ # Candidate order must not reveal which article owns the fixture.
+ $rankedArticles = @($rankedArticles | Sort-Object path)
+ [pscustomobject]@{
+ protocol = "Run only $leafPath over this case. Follow leafInstructions exactly, evaluate the ranked candidate article rows, open matching articles in full, and copy every finding id verbatim from candidateArticles[].path."
+ skill = $leafPath
+ leafInstructions = $leafInstructions
+ knowledgeIndex = $domainIndexName
+ candidateArticles = $rankedArticles
+ resultSchema = $resultSchema
+ cases = @($requestCase)
+ } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $PrepareDirectory "request-$($requestCase.id).json") -Encoding UTF8
+ }
+ }
+ Write-Host "Prepared $($cases.Count) neutral fixture(s) in $PrepareDirectory." -ForegroundColor Green
+}
+
+if (-not $ResultsPath -and -not $ResultsDirectory) {
+ & (Join-Path $PSScriptRoot 'Test-ReviewContract.ps1') -Root $Root
+ Write-Host "Review fixture validation PASSED: $($cases.Count) cases cover $($leafDomains.Count) leaf domains." -ForegroundColor Green
+ exit 0
+}
+
+$resultCases = [System.Collections.Generic.List[object]]::new()
+if ($ResultsDirectory) {
+ if (-not (Test-Path -LiteralPath $ResultsDirectory -PathType Container)) {
+ throw "Results directory not found: $ResultsDirectory"
+ }
+ $resultFiles = @(Get-ChildItem -LiteralPath $ResultsDirectory -File -Filter 'result-case-*.json')
+ if (-not $resultFiles.Count) {
+ $resultFiles = @(Get-ChildItem -LiteralPath $ResultsDirectory -File -Filter 'result-*.json')
+ }
+ if (-not $resultFiles.Count) {
+ throw "No result-case-*.json or result-*.json files found in: $ResultsDirectory"
+ }
+ foreach ($resultFile in $resultFiles) {
+ try {
+ $resultDocument = Get-Content -LiteralPath $resultFile.FullName -Raw | ConvertFrom-Json
+ } catch {
+ $problems.Add("$($resultFile.Name): invalid JSON: $($_.Exception.Message)") | Out-Null
+ continue
+ }
+ if ($resultDocument.PSObject.Properties.Name -notcontains 'cases') {
+ $problems.Add("$($resultFile.Name): result must contain a 'cases' array.") | Out-Null
+ continue
+ }
+ foreach ($resultCase in @($resultDocument.cases)) {
+ $resultCases.Add($resultCase) | Out-Null
+ }
+ }
+} else {
+ if (-not (Test-Path -LiteralPath $ResultsPath -PathType Leaf)) {
+ throw "Results file not found: $ResultsPath"
+ }
+ $resultDocument = Get-Content -LiteralPath $ResultsPath -Raw | ConvertFrom-Json
+ foreach ($resultCase in @($resultDocument.cases)) {
+ $resultCases.Add($resultCase) | Out-Null
+ }
+}
+
+$resultById = @{}
+$modelToManifestId = @{}
+foreach ($case in $cases) {
+ $manifestId = [string]$case.id
+ $modelToManifestId[(Get-ModelCaseId -ManifestId $manifestId)] = $manifestId
+ # Also accept manifest IDs for maintainers generating local oracle results.
+ $modelToManifestId[$manifestId] = $manifestId
+}
+foreach ($resultCase in @($resultCases)) {
+ $rawResultId = [string]$resultCase.id
+ if (-not $modelToManifestId.ContainsKey($rawResultId)) {
+ $problems.Add("Results contain unknown case id: $rawResultId") | Out-Null
+ continue
+ }
+ $resultId = $modelToManifestId[$rawResultId]
+ if ($resultById.ContainsKey($resultId)) {
+ $problems.Add("Results contain duplicate case id: $rawResultId") | Out-Null
+ } else {
+ $resultById[$resultId] = $resultCase
+ }
+}
+
+$positiveTotal = 0
+$positivePassed = 0
+$cleanTotal = 0
+$cleanPassed = 0
+foreach ($case in $cases) {
+ $id = [string]$case.id
+ if (-not $resultById.ContainsKey($id)) {
+ $problems.Add("Results missing case: $id") | Out-Null
+ continue
+ }
+
+ $findingIds = @(
+ @($resultById[$id].findings) | ForEach-Object {
+ if ($_ -is [string]) { [string]$_ } else { [string]$_.id }
+ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique
+ )
+ $expected = @($case.expected | ForEach-Object { [string]$_ })
+
+ if ($expected.Count) {
+ $positiveTotal++
+ $missing = @($expected | Where-Object { $findingIds -notcontains $_ })
+ $unexpected = @($findingIds | Where-Object { $expected -notcontains $_ })
+ if (-not $missing.Count -and -not $unexpected.Count) {
+ $positivePassed++
+ } else {
+ if ($missing.Count) { $problems.Add("${id}: missing expected finding(s): $($missing -join ', ')") | Out-Null }
+ if ($unexpected.Count) { $problems.Add("${id}: unexpected finding(s): $($unexpected -join ', ')") | Out-Null }
+ }
+ } else {
+ $cleanTotal++
+ if (-not $findingIds.Count) {
+ $cleanPassed++
+ } else {
+ $problems.Add("${id}: clean control produced finding(s): $($findingIds -join ', ')") | Out-Null
+ }
+ }
+}
+
+$recall = if ($positiveTotal) { $positivePassed / $positiveTotal } else { 0 }
+$cleanRate = if ($cleanTotal) { $cleanPassed / $cleanTotal } else { 0 }
+if ($recall -lt [double]$manifest.minimumExpectedRecall) {
+ $problems.Add("Expected-finding recall $recall is below $($manifest.minimumExpectedRecall).") | Out-Null
+}
+if ($cleanRate -lt [double]$manifest.minimumCleanRate) {
+ $problems.Add("Clean-control rate $cleanRate is below $($manifest.minimumCleanRate).") | Out-Null
+}
+
+if ($problems.Count) {
+ Write-Host "Review evaluation FAILED ($($problems.Count) problem(s)):" -ForegroundColor Red
+ $problems | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
+ exit 1
+}
+
+Write-Host "Review evaluation PASSED: recall=$recall ($positivePassed/$positiveTotal), clean-rate=$cleanRate ($cleanPassed/$cleanTotal)." -ForegroundColor Green
+exit 0
|