mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 17:36:53 +01:00
Fix lifecycle compatibility guidance (#93)
* Fix lifecycle compatibility guidance Correct high-confidence Business Central guidance and samples for upgrade tags, collectible errors, trigger semantics, obsoletion, events, interfaces, API contracts, and test transactions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e05a43e7-6448-4d67-9c73-798523f5d945 * Address guidance review findings Gate SecretText guidance to BC23 and clarify that the collectible-error sample intentionally emits a message-only blocking aggregate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e05a43e7-6448-4d67-9c73-798523f5d945 --------- Co-authored-by: Jesper Schulz-Wedde <jesper.schulzwedde@microsoft.com>
This commit is contained in:
parent
aca3986fd0
commit
5706959e4a
32 changed files with 201 additions and 104 deletions
|
|
@ -1,7 +1,7 @@
|
|||
codeunit 50305 "Net Amount Api Good"
|
||||
{
|
||||
// Old name kept and marked obsolete: callers still compile but get a warning
|
||||
// pointing at the replacement, with a tag recording the removal target version.
|
||||
// Old name kept during the warning window. The tag records when obsoletion
|
||||
// began; a later release deletes the method after consumers have migrated.
|
||||
[Obsolete('Use CalculateNetAmount instead.', '25.0')]
|
||||
procedure CalcNet(GrossAmount: Decimal; TaxRate: Decimal): Decimal
|
||||
begin
|
||||
|
|
|
|||
|
|
@ -11,16 +11,16 @@ application-area: [all]
|
|||
|
||||
## Description
|
||||
|
||||
Deleting or renaming a published procedure (or object) in a single release is a hard break: dependent extensions that reference it stop compiling the moment they pick up the new version, with no warning window to migrate. AL provides a staged deprecation lifecycle precisely so consumers get advance notice. For a procedure, apply the `[Obsolete('reason', 'tag')]` attribute: the member keeps working but every caller gets a compiler warning naming the replacement and the target version. The member stays through a deprecation window — at least one major release — before it is finally removed. Object- and field-level members use the matching `ObsoleteState = Pending` → `Removed` property progression. LLMs trained to "clean up" code often delete or rename the old member immediately, skipping the window entirely.
|
||||
Deleting or renaming a published procedure (or object) in a single release is a hard break: dependent extensions that reference it stop compiling the moment they pick up the new version, with no warning window to migrate. AL provides staged deprecation so consumers get advance notice. A procedure uses `[Obsolete('reason', 'tag')]`: it remains callable but callers receive a compiler warning naming the replacement and the version in which obsoletion began. Methods do not have `ObsoleteState`; after the deprecation window, the method is deleted, commonly through versioned preprocessor cleanup. Objects and fields instead use the `ObsoleteState = Pending` to `Removed` property progression.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When a published procedure is superseded, keep it in place and mark it `[Obsolete('Use CalculateNetAmount instead.', '25.0')]`, where the message names the replacement and the tag records the target version for removal. Have the obsolete member forward to the new one so behavior is preserved during the window. Only after the deprecation window has elapsed — a later release — change its state to removed. This gives every dependent app a compile-time signal and time to migrate before anything actually disappears.
|
||||
When a published procedure is superseded, keep it in place and mark it `[Obsolete('Use CalculateNetAmount instead.', '25.0')]`, where the message names the replacement and the tag records when the method became obsolete. Have the obsolete member forward to the new one so behavior is preserved during the window. Only after the deprecation window has elapsed should a later release delete the method. For an object or field, use `Pending` during the warning window and `Removed` afterward.
|
||||
|
||||
See sample: `deprecate-public-members-with-the-obsolete-lifecycle.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Renaming or deleting the published `CalcNet` procedure in place — replacing it with `CalculateNetAmount` and nothing else — so consumers calling `CalcNet` break immediately with no deprecation notice. Detection: a previously shipped non-`local` procedure that vanished or was renamed between versions with no `[Obsolete]` marker left behind on a kept member. Mark it obsolete and keep it for a window instead.
|
||||
Renaming or deleting the published `CalcNet` procedure in place — replacing it with `CalculateNetAmount` and nothing else — so consumers calling `CalcNet` break immediately with no deprecation notice. Detection: a previously shipped non-`local` procedure that vanished or was renamed between versions with no `[Obsolete]` marker left behind during a prior warning window. Do not suggest `ObsoleteState = Removed` for a method; that property belongs to supported object and element types.
|
||||
|
||||
See sample: `deprecate-public-members-with-the-obsolete-lifecycle.bad.al`.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
codeunit 50320 "Payment Client Good"
|
||||
{
|
||||
var
|
||||
AccessToken: Text;
|
||||
AccessToken: SecretText;
|
||||
|
||||
// Credential flows inward through an internal setter and never leaves the object.
|
||||
internal procedure SetAccessToken(NewToken: Text)
|
||||
// Credential remains SecretText as it flows inward and is stored.
|
||||
internal procedure SetAccessToken(NewToken: SecretText)
|
||||
begin
|
||||
AccessToken := NewToken;
|
||||
end;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
bc-version: [23..]
|
||||
domain: breaking-changes
|
||||
keywords: [sensitive-data, secrettext, token, credential, public-api, access-boundary]
|
||||
technologies: [al]
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ table 50311 "Customer Profile Bad"
|
|||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
// Breaking: the published "Email" field was renamed in place. Dependent
|
||||
// extensions that reference "Email" stop compiling, and the data stored in
|
||||
// the old column is orphaned on upgrade.
|
||||
// Breaking: the published field was renamed while retaining ID 2.
|
||||
// AppSourceCop AS0005 rejects the compatibility change; retaining the ID
|
||||
// does not by itself mean the stored column was dropped and re-created.
|
||||
field(2; "Contact Email"; Text[80]) { }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,20 +7,20 @@ countries: [w1]
|
|||
application-area: [all]
|
||||
---
|
||||
|
||||
# Obsolete published table fields instead of deleting or renaming them
|
||||
# Obsolete published table fields instead of deleting or renumbering them
|
||||
|
||||
## Description
|
||||
|
||||
A table field that has shipped carries two contracts at once: extensions reference it by name, and the database holds data in its column. Deleting the field, or renaming it (which the platform treats as drop-plus-add), breaks dependent code at compile time and discards the stored data — a silent data-loss event on upgrade. The fix is the same staged lifecycle used for objects: set `ObsoleteState = Pending` together with `ObsoleteReason` and an `ObsoleteTag` naming the target version, ship the new field alongside, migrate data during the window, and only switch the old field to `ObsoleteState = Removed` in a later release once nothing depends on it. LLMs often "tidy" a schema by renaming a field in place, not realizing this is both a breaking change and a data-loss risk.
|
||||
A shipped table field carries both a source-level contract and persisted data. Renaming a field while retaining its ID is prohibited by AppSourceCop AS0005 and can break dependent extensions, but it is not inherently a drop-and-readd operation and should not be described as automatic data loss. Deleting the field or replacing it under a different ID is the data-loss risk: the old field storage is no longer represented unless data is migrated. The supported path is to keep the old field and obsolete it, add a replacement under a new ID, and migrate values before later removal.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Add the replacement field, then mark the old field `ObsoleteState = Pending` with an `ObsoleteReason` that names the replacement and an `ObsoleteTag` carrying the target version (for example `'25.0'`). Keep the obsolete field readable so an upgrade codeunit can copy its data into the new field during the deprecation window. Move it to `ObsoleteState = Removed` only in a later major version, after the window has passed and data has migrated.
|
||||
Add the replacement field under a new ID, then mark the old field `ObsoleteState = Pending` with an `ObsoleteReason` that names the replacement and an `ObsoleteTag` recording the obsoletion version. Keep the old field readable so an upgrade codeunit can copy its data during the deprecation window. Move it to `ObsoleteState = Removed` only in a later release, after the window has passed and data has migrated.
|
||||
|
||||
See sample: `obsolete-table-fields-instead-of-deleting-them.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Renaming the published `Email` field to `Contact Email` directly in the table — or deleting it — so dependent extensions that reference `Email` break and the column's stored values are orphaned on upgrade. Detection: a previously shipped field removed or renamed in a table or table extension with no `ObsoleteState = Pending` step preserving the original. Obsolete the field through the lifecycle instead.
|
||||
Renaming published `Email` to `Contact Email` with the same ID violates the compatibility contract and AS0005, even though the retained ID does not itself imply a fresh empty column. Deleting `Email` or moving the replacement to another ID without migration additionally risks losing its stored values. Detection: a previously shipped field removed, renumbered, or renamed with no retained `Pending` field and migration path.
|
||||
|
||||
See sample: `obsolete-table-fields-instead-of-deleting-them.bad.al`.
|
||||
|
|
|
|||
|
|
@ -15,10 +15,13 @@ codeunit 50185 "Collect Errors Good Sample"
|
|||
until Item.Next() = 0;
|
||||
|
||||
if HasCollectedErrors() then begin
|
||||
CollectedErrors := GetCollectedErrors();
|
||||
// The default is false; true retrieves and clears the collection.
|
||||
CollectedErrors := GetCollectedErrors(true);
|
||||
// This blocking aggregate intentionally retains messages only.
|
||||
foreach CollectedError in CollectedErrors do
|
||||
ErrorText += CollectedError.Message() + '\';
|
||||
Message('The following must be fixed before posting:\%1', ErrorText);
|
||||
Error(ErrorInfo.Create(
|
||||
StrSubstNo('The following must be fixed before posting:\%1', ErrorText), false));
|
||||
end;
|
||||
end;
|
||||
}
|
||||
|
|
@ -30,8 +33,10 @@ codeunit 50186 "Collect Errors Item Check"
|
|||
trigger OnRun()
|
||||
begin
|
||||
if Rec.Description = '' then
|
||||
Error('Item %1 has no description.', Rec."No.");
|
||||
Error(ErrorInfo.Create(
|
||||
StrSubstNo('Item %1 has no description.', Rec."No."), true));
|
||||
if Rec."Unit Cost" <= 0 then
|
||||
Error('Item %1 must have a positive unit cost.', Rec."No.");
|
||||
Error(ErrorInfo.Create(
|
||||
StrSubstNo('Item %1 must have a positive unit cost.', Rec."No."), true));
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
bc-version: [19..]
|
||||
domain: error-handling
|
||||
keywords: [collectible-errors, errorbehavior, collect, getcollectederrors, hascollectederrors, validation, batch]
|
||||
technologies: [al]
|
||||
|
|
@ -11,16 +11,16 @@ application-area: [all]
|
|||
|
||||
## Description
|
||||
|
||||
By default a procedure stops on the first `Error`, so a user fixing ten bad rows must rerun the operation ten times. The collectible-errors feature postpones error handling to the end of the call: a procedure attributed `[ErrorBehavior(ErrorBehavior::Collect)]` keeps running as errors occur and gathers them, so all failures can be presented together. The collected errors are read with `HasCollectedErrors()` and `GetCollectedErrors()` (which returns a `List of [ErrorInfo]`); `ClearCollectedErrors()` empties the buffer. This is a platform mechanism most LLMs are unaware of — they reach for a manually concatenated `Text` buffer or a temporary error table instead.
|
||||
By default a procedure stops on the first `Error`, so a user fixing ten bad rows must rerun the operation ten times. The collectible-errors feature postpones error handling to the end of the call: a procedure attributed `[ErrorBehavior(ErrorBehavior::Collect)]` keeps running as collectible errors occur and gathers them, so all failures can be presented together. `GetCollectedErrors()` returns a `List of [ErrorInfo]` for the handler to inspect, but does not clear the collection by default; pass `true` to retrieve and clear in one call, or call `ClearCollectedErrors()` explicitly after retrieving. A handler can copy record information into a custom error page as Microsoft Learn demonstrates, or deliberately format only the messages into a final blocking error as this article's sample does.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Mark the orchestrating procedure `[ErrorBehavior(ErrorBehavior::Collect)]` and run each item's validation so one failure doesn't abandon the rest — typically by calling the per-item routine through `Codeunit.Run`. When the run finishes, inspect `HasCollectedErrors()` and surface `GetCollectedErrors()` to the user as a single list. Always handle the collected errors yourself: the platform's own guidance is that any errors still in the collected list when the procedure ends are concatenated into one dialog, which is hard for users to read.
|
||||
Mark the orchestrating procedure `[ErrorBehavior(ErrorBehavior::Collect)]` and run each item's validation so one failure doesn't abandon the rest — typically by calling the per-item routine through `Codeunit.Run`. When the run finishes, inspect `HasCollectedErrors()`, retrieve and clear the list with `GetCollectedErrors(true)`, and fail the operation with the collected messages. The sample intentionally produces a text aggregate and does not claim to retain record/field metadata in the final error. If that metadata is needed, map each `ErrorInfo` to a custom error UI before clearing, following the Microsoft Learn pattern. Do not replace validation failure with `Message`: clearing collected errors suppresses the platform failure, so the custom handler must still block the invalid operation.
|
||||
|
||||
See sample: `collect-validation-errors-with-errorbehavior.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Two shapes signal trouble. The first is hand-rolled accumulation — appending messages to a `Text` variable and showing them at the end — which reimplements the platform feature, loses each error's `ErrorInfo` structure, and skips telemetry classification. The second is applying `[ErrorBehavior(ErrorBehavior::Collect)]` but never calling `HasCollectedErrors`/`GetCollectedErrors`, so every collected error spills into the platform's concatenated end-of-procedure dialog. Detection: a `Collect` attribute with no matching `GetCollectedErrors` call, or a per-row loop that builds an error string by concatenation.
|
||||
Three shapes signal trouble. Hand-rolled accumulation reimplements collection and prevents the handler from receiving individual `ErrorInfo` values. A `Collect` procedure that never handles the collection falls back to the concatenated platform dialog. Finally, code that calls parameterless `GetCollectedErrors()`, assumes it cleared the list, and only shows a `Message` can both leave the errors collected and allow invalid processing to continue.
|
||||
|
||||
See sample: `collect-validation-errors-with-errorbehavior.bad.al`.
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ codeunit 50291 "New OnBefore Bad Sample"
|
|||
begin
|
||||
Total := 100;
|
||||
|
||||
// Anti-pattern: IsHandled was bolted onto the existing
|
||||
// OnAfterCalculateTotal, changing its contract and breaking every
|
||||
// subscriber that matched the original signature.
|
||||
// Anti-pattern: IsHandled was bolted onto the existing OnAfter event.
|
||||
// Regardless of compiler compatibility, this changes a notification
|
||||
// into an override contract that existing subscribers did not expect.
|
||||
OnAfterCalculateTotal(SalesHeader, Total, IsHandled);
|
||||
end;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ codeunit 50260 "Reuse Event Good Sample"
|
|||
IsHandled := false;
|
||||
// A single event, extended with CustomerNo appended at the end, covers
|
||||
// the need; no second event is raised beside it.
|
||||
OnBeforeProcessOrder(SalesHeader, CustomerNo, IsHandled);
|
||||
OnBeforeProcessOrder(SalesHeader, IsHandled, CustomerNo);
|
||||
if IsHandled then
|
||||
exit;
|
||||
end;
|
||||
|
||||
[IntegrationEvent(false, false)]
|
||||
local procedure OnBeforeProcessOrder(var SalesHeader: Record "Sales Header"; CustomerNo: Code[20]; var IsHandled: Boolean)
|
||||
local procedure OnBeforeProcessOrder(var SalesHeader: Record "Sales Header"; var IsHandled: Boolean; CustomerNo: Code[20])
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,13 +20,14 @@ codeunit 50225 "Reservation Post Good Sample"
|
|||
var
|
||||
IsHandled: Boolean;
|
||||
begin
|
||||
IsHandled := false;
|
||||
OnBeforeReserve(ReservationEntry, IsHandled);
|
||||
if IsHandled then
|
||||
exit;
|
||||
|
||||
ReservationEntry.Reserved := true;
|
||||
ReservationEntry.Modify(true);
|
||||
if not IsHandled then begin
|
||||
ReservationEntry.Reserved := true;
|
||||
ReservationEntry.Modify(true);
|
||||
end;
|
||||
|
||||
// OnAfter reports completion whether a subscriber or the base body handled it.
|
||||
OnAfterReserve(ReservationEntry);
|
||||
end;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ codeunit 50220 "Shipping Charge Good Sample"
|
|||
begin
|
||||
// Give extensions a sanctioned seam to replace the calculation, then
|
||||
// skip the default logic when a subscriber has handled it.
|
||||
IsHandled := false;
|
||||
OnBeforeCalculateShippingCharge(OrderAmount, Charge, IsHandled);
|
||||
if IsHandled then
|
||||
exit(Charge);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ An `enum` that `implements` an interface maps each value to a codeunit through t
|
|||
|
||||
## Best Practice
|
||||
|
||||
On any extensible enum that implements an interface, set `DefaultImplementation = <Interface> = <Codeunit>;` at the enum level, pointing at a safe implementation that does nothing harmful. Values with their own `Implementation` keep using it; every other value — including ones added later by extensions — resolves to the default instead of failing. For the distinct case of an out-of-range integer that matches no declared value, pair it with `UnknownValueImplementation`. The result is that a consumer can assign any enum value to the interface variable and call through it without a runtime guard.
|
||||
On any extensible enum that implements an interface, set `DefaultImplementation = <Interface> = <Codeunit>;` at the enum level, pointing at a safe implementation that does nothing harmful. Values with their own `Implementation` keep using it; declared values without one resolve to the default. For an ordinal that matches no currently declared value — for example persisted data left after an enum extension is uninstalled — runtime 7.0 and later can use `UnknownValueImplementation` as a distinct fallback. Do not recommend that property to apps targeting an earlier runtime.
|
||||
|
||||
See sample: `set-defaultimplementation-on-enum.good.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ application-area: [all]
|
|||
|
||||
## Best Practice
|
||||
|
||||
Default to `AutoRollback`: it opens a write transaction at the start of the test, runs the test body, and rolls back at the end, leaving the database in its original state. Pick `AutoCommit` only when the code under test genuinely calls `Commit` — posting routines, job-queue handlers, integration flows — and pair that test's codeunit with a `TestIsolation`-enabled test runner so committed changes are reverted at a higher scope. Pick `None` only for read-only tests or tests that drive UI code without writing from the test method itself, for example tests that validate calculation formulas or read-only projections.
|
||||
Default to `AutoRollback`: it opens a write transaction at the start of the test, runs the test body, and rolls back at the end, leaving the database in its original state. Pick `AutoCommit` only when the code under test genuinely calls `Commit` — posting routines, job-queue handlers, integration flows — and make the test exercise that commit path. Pair the test codeunit with a `TestIsolation`-enabled test runner so committed changes are reverted at a higher scope. Pick `None` only for read-only tests or tests that drive UI code without writing from the test method itself.
|
||||
|
||||
See sample: `transactionmodel-attribute-governs-test-transactions.good.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,18 +11,18 @@ application-area: [all]
|
|||
|
||||
## Description
|
||||
|
||||
`DataTransfer` writes directly at the database layer. It does not invoke field `OnValidate` triggers, table `OnModify` triggers, or any `OnAfterModifyEvent` / `OnBeforeValidate...` event subscribers that a normal `Record.Modify(true)` would. This is precisely what makes it fast — and precisely what makes it a footgun when the field being updated has validation logic that other code relies on. The receiving code never gets the signal that a row changed, derived fields stay stale, audit hooks do not run.
|
||||
`DataTransfer` writes sets directly at the database layer, so row-based triggers and events do not run. For `CopyFields`, that includes the table `OnModify` trigger and `OnBeforeModifyEvent`/`OnAfterModifyEvent`; direct field assignment also does not call field `OnValidate` or its validation events. These are separate behaviors: `Record.Validate(Field, Value)` runs field validation, while `Record.Modify(true)` runs the table `OnModify` trigger. Calling `Modify(true)` does not retroactively validate assigned fields.
|
||||
|
||||
For *new fields and tables added in the same change* this is fine: nothing yet depends on the validation. For *pre-existing fields with validation logic*, `DataTransfer` quietly bypasses business logic that may be load-bearing for posting, calculation, or integration scenarios.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use `DataTransfer` only when the field or table is new in the same change — initial population is the canonical safe case. When updating a pre-existing field that has validation logic, either use `Modify(true)` to honour the triggers, or, if `DataTransfer` is still required for performance reasons, leave a comment that explicitly states "validation triggers and event subscribers are intentionally not raised" and verify with the field's owner that this is safe.
|
||||
Use `DataTransfer` when set-based transfer is safe and row-level business logic is intentionally unnecessary — initial population of a new field is the canonical case. When an existing field's validation must run, loop through records and call `Validate(Field, Value)`; if the table's modify trigger must also run, follow with `Modify(true)`. If performance requires `DataTransfer`, document exactly which field-validation and row-modification triggers or subscribers are intentionally bypassed and verify that derived data remains correct.
|
||||
|
||||
See sample: `datatransfer-skips-triggers-and-subscribers.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Reaching for `DataTransfer` to update an existing field with non-trivial `OnValidate` logic, without a comment and without confirming that subscribers can be skipped. The upgrade succeeds; runtime behaviour drifts silently.
|
||||
Reaching for `DataTransfer` to update an existing field with non-trivial `OnValidate` or `OnModify` logic, without confirming that both validation and row-modification subscribers can be skipped. Replacing it with only `Modify(true)` is also incomplete when field validation is required; call `Validate` for that field first.
|
||||
|
||||
See sample: `datatransfer-skips-triggers-and-subscribers.bad.al`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -7,27 +7,26 @@ countries: [w1]
|
|||
application-area: [all]
|
||||
---
|
||||
|
||||
# Mark obsolete elements with `ObsoleteState`, `ObsoleteReason`, and `ObsoleteTag`
|
||||
# Give every obsolete element a reason and tag
|
||||
|
||||
## Description
|
||||
|
||||
When a procedure, field, table, page, or enum value is being retired, AL requires three pieces of metadata to declare the deprecation:
|
||||
AL has two obsoletion mechanisms, depending on the symbol:
|
||||
|
||||
- `ObsoleteState` — `Pending` while the element still exists but is being phased out, `Removed` once it should no longer be used.
|
||||
- `ObsoleteReason` — a short human-readable string explaining what to use instead. Tooling and downstream consumers surface this when warning callers.
|
||||
- `ObsoleteTag` — a stable version-like marker (typically the release version in which the deprecation was introduced, e.g. `'22.0'`).
|
||||
- Objects, fields, enum types, and enum values use the `ObsoleteState`, `ObsoleteReason`, and `ObsoleteTag` properties. `Pending` warns while the element remains available; `Removed` blocks references.
|
||||
- Methods, variables, events, and other symbols use `[Obsolete('reason', 'tag')]`. They do not have an `ObsoleteState` property.
|
||||
|
||||
Omitting `ObsoleteReason` or `ObsoleteTag` leaves consumers with `ObsoleteState = Pending` but no guidance and no traceability. Declaring `ObsoleteState = Removed` without a reason or tag is the same failure with a stronger blast radius.
|
||||
In both forms, the reason should name the replacement and the tag should identify when the element became obsolete. Empty or missing guidance leaves consumers without an actionable migration path.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Every obsoleted element carries all three properties together. The reason names the replacement explicitly; the tag is the version in which the deprecation was introduced and stays stable for the life of the deprecation.
|
||||
For an object or field, set all three properties together. For a method, variable, or event, provide both `[Obsolete]` arguments. Keep the original tag stable through the lifecycle rather than changing it to a planned removal version.
|
||||
|
||||
See sample: `obsoletion-requires-reason-and-tag.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Setting only `ObsoleteState = Pending;` (or `Removed`) without `ObsoleteReason` and `ObsoleteTag`. Callers see a warning with no explanation, and the deprecation cannot be tracked by version.
|
||||
Setting only `ObsoleteState = Pending`/`Removed` on an object or field, or using `[Obsolete('', '')]` on a method, variable, or event. Both forms produce deprecation metadata without useful replacement guidance or traceability.
|
||||
|
||||
See sample: `obsoletion-requires-reason-and-tag.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,22 +7,22 @@ countries: [w1]
|
|||
application-area: [all]
|
||||
---
|
||||
|
||||
# Register every upgrade tag with the platform via an event subscriber
|
||||
# Register upgrade tags that must be seeded for new companies
|
||||
|
||||
## Description
|
||||
|
||||
The `Upgrade Tag` codeunit only recognizes a tag if the tag was published to the platform through one of two events on that codeunit: `OnGetPerCompanyUpgradeTags` for tags set inside `OnUpgradePerCompany`, and `OnGetPerDatabaseUpgradeTags` for tags set inside `OnUpgradePerDatabase`. A tag that is `Set` and `Has`-checked in code but never added to one of these lists is unknown to the platform — its semantics around skip-on-reinstall, telemetry, and operator queries do not apply.
|
||||
`SetUpgradeTag(Tag)` directly records a completed per-company upgrade step; `HasUpgradeTag(Tag)` can then guard that step on later upgrades. The `OnGetPerCompanyUpgradeTags` subscriber serves a different path: it contributes tags to the list used by `SetAllUpgradeTags()` when a new company is initialized, marking historical upgrade steps complete so they do not run against a company that starts on the current schema.
|
||||
|
||||
The registration scope must match where the tag is set: a tag used from `OnUpgradePerCompany` registers in `OnGetPerCompanyUpgradeTags`; a tag used from `OnUpgradePerDatabase` registers in `OnGetPerDatabaseUpgradeTags`. Crossing the scopes silently breaks the tag.
|
||||
Registration is not install-time seeding. When an extension is installed into an existing company and a tag must start as complete, the install code must call `SetUpgradeTag` explicitly. For new-company initialization, codeunit `Company Initialize` calls `SetAllUpgradeTags`, which obtains subscriber-provided per-company tags and inserts missing ones. Database-scoped upgrade steps use `HasDatabaseUpgradeTag`/`SetDatabaseUpgradeTag` and the corresponding per-database list.
|
||||
|
||||
## Best Practice
|
||||
|
||||
For every new upgrade tag, add one line to the matching subscriber: `PerCompanyUpgradeTags.Add(MyUpgradeTag());` or `PerDatabaseUpgradeTags.Add(MyUpgradeTag());`. Place the subscribers in the same codeunit (or a dedicated "Upgrade Tag Definitions" codeunit) so the tag string and its registration stay together.
|
||||
In the upgrade codeunit, guard work with `HasUpgradeTag` and call `SetUpgradeTag` only after successful completion. Seed the same tag explicitly from `OnInstallAppPerCompany` when first-install logic should not run as a later upgrade. Also add historical per-company tags to `OnGetPerCompanyUpgradeTags` so `SetAllUpgradeTags` marks them complete for newly created companies. Keep the tag definition shared so all paths use the exact same value.
|
||||
|
||||
See sample: `register-upgrade-tags-with-subscribers.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Calling `UpgradeTag.SetUpgradeTag(MyUpgradeTag())` without ever adding `MyUpgradeTag()` to the corresponding `OnGetPerCompany...` / `OnGetPerDatabase...` subscriber.
|
||||
Assuming an `OnGetPerCompanyUpgradeTags` subscriber sets tags during extension installation, or omitting the subscriber and allowing old upgrade steps to run when `SetAllUpgradeTags` initializes a new company. The subscriber supplies a list; only `SetAllUpgradeTags` or an explicit `SetUpgradeTag` call persists it.
|
||||
|
||||
See sample: `register-upgrade-tags-with-subscribers.bad.al`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ application-area: [all]
|
|||
|
||||
## Description
|
||||
|
||||
A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upgrade`. The platform then dispatches the `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on that codeunit during upgrade. A codeunit without `Subtype = Upgrade` — even one that declares an `OnUpgradePerCompany` trigger — is not an upgrade codeunit, and reviewers ignore it for upgrade concerns. Conversely, any procedure invoked transitively from an `OnUpgrade...` trigger of an upgrade codeunit IS upgrade code regardless of where it lives, and the upgrade rules apply to it.
|
||||
A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upgrade`. The platform then permits and dispatches the `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on that codeunit during upgrade. A normal codeunit can contain an upgrade-like `RunUpgrade` procedure, but the platform does not discover or invoke it automatically. Conversely, any procedure invoked transitively from an `OnUpgrade...` trigger of an upgrade codeunit is upgrade code regardless of where the helper lives, and the upgrade rules apply to it.
|
||||
|
||||
## Best Practice
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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/<publisher>/<group>/<version>/<entitySet>` — so omitting any one of them yields a page that compiles yet never surfaces as a usable endpoint, or surfaces at an unexpected address. An LLM that has mostly seen ordinary list/card pages tends to treat `PageType = API` as a cosmetic switch and forgets the identifying metadata, because a normal page needs none of it. This file is remedial precisely because the missing-property failure is silent: there is no runtime error, only an endpoint that clients cannot reach.
|
||||
An API page needs `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, and a backing `SourceTable` to define its routed entity. `APIVersion` is different: it is optional at the language level and defaults to `beta`. Omitting it therefore does not mean the page has no version; it publishes under the preview contract. A production integration that intends a stable route should set a `vX.Y` version explicitly rather than rely on that default.
|
||||
|
||||
## Best Practice
|
||||
|
||||
On every `PageType = API` page set all six properties explicitly: `APIPublisher` (your publisher tag), `APIGroup` (the logical grouping for related entities), `APIVersion` (a `vX.Y` value such as `'v1.0'`), `EntityName` (singular), `EntitySetName` (plural), and `SourceTable` (the projected table). Expose the record's fields inside a single `field(...)` repeater under `area(content)`. Treat the six properties as a mandatory checklist that travels with the `PageType = API` declaration itself.
|
||||
Declare the five routing/entity properties required by the API page and set `APIVersion` explicitly for a stable published contract, for example `'v1.0'`. Expose the record's fields inside a repeater under `area(content)`. Review missing routing metadata as a malformed API definition, but review a missing `APIVersion` as unintended publication under `beta`, not as an unpublished endpoint.
|
||||
|
||||
See sample: `set-required-api-page-properties.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Writing a page with `PageType = API` and a `SourceTable` but leaving out `APIPublisher` and `APIGroup` (and, worse, omitting `SourceTable` entirely). The page compiles, so it looks finished, but the endpoint is malformed: with no publisher and group the route cannot be composed, and the entity is never published where an integration expects it. The detection signal: a `PageType = API` page missing one or more of the six identifying properties.
|
||||
Leaving out `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, or `SourceTable` leaves the API definition incomplete. A subtler contract defect is declaring all of those but omitting `APIVersion`: the page is exposed as `beta`, which is valid runtime behavior but not the explicit stable route a production client expects.
|
||||
|
||||
See sample: `set-required-api-page-properties.bad.al`.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,20 +7,20 @@ countries: [w1]
|
|||
application-area: [all]
|
||||
---
|
||||
|
||||
# Version APIs by adding a new APIVersion, not by mutating a published one
|
||||
# Version changed API shapes with a new page object
|
||||
|
||||
## Description
|
||||
|
||||
Once an API version is published, external clients depend on its exact shape — the entity name, the set of exposed fields, the key — as a frozen contract. Changing any of that on the already-published version is a breaking change delivered silently: integrations that worked yesterday fail today with no warning. The platform gives you a clean way to evolve without breaking anyone, because `APIVersion` accepts a *list* of versions on one page. The correct way to change a published API is to add the new version (`'v2.0'`) alongside the existing one (`'v1.0'`) — or publish a new API page for it — so both contracts are served side by side and clients migrate on their own schedule. LLMs tend to "fix" an API by editing the live version in place, because in ordinary code you just change what's wrong; this file is remedial because a published API version is an immutable contract in a way ordinary internal code is not.
|
||||
Once an API version is published, external clients depend on its exact shape — entity names, fields, keys, and behavior — as a stable contract. `APIVersion` can list several versions on one API page, but every listed route is generated from that same page object and therefore exposes the same shape. Adding `'v2.0'` to a page and then changing its fields changes what both `v1.0` and `v2.0` serve. To preserve the v1 shape while introducing a different v2 shape, keep the v1 page unchanged and create a separate page object for v2.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When a published API must change shape, keep the old version's contract intact and add the new one to the `APIVersion` list — `APIVersion = 'v2.0', 'v1.0';`. The page now serves both `v1.0` (unchanged) and `v2.0` (carrying the new shape), so existing clients keep working while new clients adopt `v2.0`. Retire the old version only after consumers have migrated.
|
||||
Keep the existing page object and its `APIVersion = 'v1.0'` contract unchanged. Copy the page to a new object ID, set that object's `APIVersion = 'v2.0'`, and make the v2-only shape changes there. A multi-value `APIVersion` list is appropriate only when the exact same page shape is supported under each listed version.
|
||||
|
||||
See sample: `version-apis-by-adding-not-mutating-published-versions.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Editing the published `v1.0` page in place — renaming its `EntityName` or removing an exposed field — so the single declared version now serves a different contract than the one clients integrated against. Every consumer of the old shape breaks without notice. The detection signal: a change that renames the entity or removes a field on an existing published `APIVersion` instead of adding a new version to the list.
|
||||
Editing the published `v1.0` page in place breaks its clients. So does adding `v2.0` to that same page and assuming subsequent field changes apply only to v2: both routes use one object shape. The detection signal is a breaking shape change without a separate API page object retaining the old version.
|
||||
|
||||
See sample: `version-apis-by-adding-not-mutating-published-versions.bad.al`.
|
||||
|
|
|
|||
|
|
@ -84,14 +84,14 @@ Output conforms to the DO output contract. A populated example:
|
|||
"skill": { "id": "al-web-services-review", "version": 1 },
|
||||
"outcome": "completed",
|
||||
"summary": {
|
||||
"counts": { "blocker": 0, "major": 1, "minor": 1, "info": 0 },
|
||||
"counts": { "blocker": 0, "major": 0, "minor": 2, "info": 0 },
|
||||
"coverage": { "worklist-size": 2, "items-evaluated": 2 }
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"id": "microsoft/knowledge/web-services/set-required-api-page-properties.md",
|
||||
"severity": "major",
|
||||
"message": "This PageType = API page declares a SourceTable but omits APIPublisher and APIGroup, so the endpoint route cannot be composed and the entity is never published. Declare all six required API page properties.",
|
||||
"severity": "minor",
|
||||
"message": "This PageType = API page omits APIVersion, so it is exposed under beta by default rather than an explicit stable contract. Declare the intended version, such as APIVersion = 'v1.0'.",
|
||||
"location": {
|
||||
"file": "src/Api/CustomerApi.Page.al",
|
||||
"line": 3,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue