Add breaking-changes knowledge domain and review leaf skill (#44)

Seed a new breaking-changes (AL API stability) knowledge domain with six
articles plus good/bad AL samples, a new al-breaking-changes-review leaf
skill, and minimal wiring into al-code-review and the README.

Co-authored-by: Jesper Schulz-Wedde <jesper.schulzwedde@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-06-25 12:33:30 +02:00 committed by GitHub
parent f19f0618fb
commit 6140a52b03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 503 additions and 3 deletions

View file

@ -0,0 +1,21 @@
codeunit 50326 "Order Processor Bad"
{
// Anti-pattern: every helper is public by default, exposing implementation
// detail as a de-facto API. Each becomes a contract that cannot be changed
// without risking breakage for consumers that bound to it.
procedure ProcessOrder(OrderNo: Code[20])
begin
ValidateOrder(OrderNo);
PostOrder(OrderNo);
end;
procedure ValidateOrder(OrderNo: Code[20])
begin
if OrderNo = '' then
Error('Order number is required.');
end;
procedure PostOrder(OrderNo: Code[20])
begin
end;
}

View file

@ -0,0 +1,21 @@
codeunit 50325 "Order Processor Good"
{
// Supported, stable entry point intentionally public.
procedure ProcessOrder(OrderNo: Code[20])
begin
ValidateOrder(OrderNo);
PostOrder(OrderNo);
end;
// In-app reuse only internal, so it is not part of the external contract.
internal procedure ValidateOrder(OrderNo: Code[20])
begin
if OrderNo = '' then
Error('Order number is required.');
end;
// Implementation detail confined to this object local.
local procedure PostOrder(OrderNo: Code[20])
begin
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: breaking-changes
keywords: [access-modifier, internal, local, public, protected, scope, encapsulation]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Choose access modifiers deliberately
## Description
Access is a decision about what you are willing to support forever. The moment a procedure or object is reachable from another extension — no `local`, or a removed `[Scope('OnPrem')]` — it becomes a contract: callers bind to it, and changing or removing it later is a breaking change. The safe default is the narrowest access that works. Use `local` for implementation detail confined to one object, `internal` for code shared within the app but not exposed to consumers, and `protected var` for state intended as an inheritance point for extension objects. Reserve `public` for the deliberate, supported entry points you intend to maintain as a stable API. LLMs tend to make everything public "to be safe," which inverts the rule and turns every helper into an accidental contract.
## Best Practice
Start everything `local` or `internal` and promote a member to `public` only when you have decided to support it as a stable contract. Expose a small, intentional surface — the supported entry point — and keep validation, posting, and helper routines `internal` for in-app reuse or `local` when single-object. Do not drop `[Scope('OnPrem')]` without intent, since that too widens the contract. Every public member is a maintenance commitment; spend them deliberately.
See sample: `choose-access-modifiers-deliberately.good.al`.
## Anti Pattern
Declaring every procedure `public` by default, so internal helpers like `ValidateOrder` and `PostOrder` become a de-facto API that consumers bind to and that can no longer be changed freely. Detection: an object where implementation-detail procedures carry no access modifier or are `public` without a reason to support them externally. Default them to `internal`/`local` and make only the intended entry point public.
See sample: `choose-access-modifiers-deliberately.bad.al`.

View file

@ -0,0 +1,10 @@
codeunit 50306 "Net Amount Api Bad"
{
// Breaking: the published CalcNet procedure was renamed outright with no
// deprecation window and no [Obsolete] marker. Every extension that called
// CalcNet breaks the instant it consumes this version.
procedure CalculateNetAmount(GrossAmount: Decimal; TaxRate: Decimal): Decimal
begin
exit(GrossAmount / (1 + TaxRate));
end;
}

View file

@ -0,0 +1,15 @@
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.
[Obsolete('Use CalculateNetAmount instead.', '25.0')]
procedure CalcNet(GrossAmount: Decimal; TaxRate: Decimal): Decimal
begin
exit(CalculateNetAmount(GrossAmount, TaxRate));
end;
procedure CalculateNetAmount(GrossAmount: Decimal; TaxRate: Decimal): Decimal
begin
exit(GrossAmount / (1 + TaxRate));
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: breaking-changes
keywords: [obsolete, deprecation, obsoletestate, obsoletetag, pending, removed, public-procedure]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Deprecate public members through the Obsolete lifecycle, never delete them outright
## 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.
## 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.
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.
See sample: `deprecate-public-members-with-the-obsolete-lifecycle.bad.al`.

View file

@ -0,0 +1,10 @@
codeunit 50301 "Discount Api Bad"
{
// Breaking: a Rate parameter was added to a procedure that already shipped.
// Every dependent extension that called CalculateDiscount(Amount) now fails
// to compile until it is changed and recompiled.
procedure CalculateDiscount(Amount: Decimal; Rate: Decimal): Decimal
begin
exit(Amount * Rate);
end;
}

View file

@ -0,0 +1,16 @@
codeunit 50300 "Discount Api Good"
{
// Published contract signature kept exactly as it shipped.
procedure CalculateDiscount(Amount: Decimal): Decimal
begin
exit(Amount * 0.05);
end;
// New capability added as a separate overload, so existing callers of
// CalculateDiscount(Amount) keep compiling. The return value is named, which
// is the one signature change that is always safe to make.
procedure CalculateDiscountWithRate(Amount: Decimal; Rate: Decimal) Discount: Decimal
begin
Discount := Amount * Rate;
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: breaking-changes
keywords: [signature, public-procedure, parameter, return-value, overload, contract]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not change the signature of a published procedure
## Description
A procedure that is reachable from outside its object — any procedure not marked `local` (and, for on-prem-scoped code, anything a dependent app can still bind to) — is a contract. Once another extension compiles against it, changing its shape breaks that extension at build time. Signature changes include adding, removing, or reordering parameters, changing a parameter or return type, and toggling a parameter between by-value and `var` (by-reference). The platform treats the procedure's identity as its full signature, so even a "compatible-looking" tweak is a new method to dependents. There is exactly one safe edit: naming a previously unnamed return value, which adds no caller obligation. LLMs routinely "improve" a public procedure in place by adding a parameter, not realizing every consumer must be recompiled.
## Best Practice
Treat a published signature as frozen. When new behavior needs more inputs, add a new procedure or overload alongside the original — for example a `CalculateDiscountWithRate(Amount; Rate)` next to the unchanged `CalculateDiscount(Amount)` — and let the old one delegate to the new one. Existing callers keep compiling; new callers opt into the richer entry point. Naming an unnamed return value is the one in-place change that is always safe.
See sample: `do-not-change-published-procedure-signatures.good.al`.
## Anti Pattern
Editing the existing public procedure's parameter list — here, adding a `Rate` parameter to `CalculateDiscount` — so every dependent extension that called the old form fails to compile. Detection: a parameter added, removed, reordered, retyped, or flipped to/from `var`, or a changed return type, on any non-`local` procedure that already shipped. Add a new overload instead.
See sample: `do-not-change-published-procedure-signatures.bad.al`.

View file

@ -0,0 +1,13 @@
codeunit 50321 "Payment Client Bad"
{
var
AccessToken: Text;
// Crossing the trust boundary: a public getter hands the raw credential to any
// caller, turning a secret into a de-facto public API that cannot be removed
// later without breaking consumers.
procedure GetAccessToken(): Text
begin
exit(AccessToken);
end;
}

View file

@ -0,0 +1,25 @@
codeunit 50320 "Payment Client Good"
{
var
AccessToken: Text;
// Credential flows inward through an internal setter and never leaves the object.
internal procedure SetAccessToken(NewToken: Text)
begin
AccessToken := NewToken;
end;
// Public API exposes only non-sensitive data a masked reference, never the token.
procedure GetMaskedReference(): Text
var
Reference: Text;
begin
Reference := LastReference();
exit('****-' + CopyStr(Reference, StrLen(Reference) - 3));
end;
local procedure LastReference(): Text
begin
exit('REF000123456');
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: breaking-changes
keywords: [sensitive-data, secrettext, token, credential, public-api, access-boundary]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not widen access to expose sensitive data through a public API
## Description
Every member you make publicly reachable becomes a contract you must keep — and when that member returns a secret, the contract leaks the secret. Widening access to a credential happens in several shapes: a public getter that returns a raw token or password, an event whose parameter carries a secret to every subscriber, or a global variable holding a key that an extension can read. Once such a surface ships, removing it is itself a breaking change, so the exposure is hard to walk back. Sensitive material — tokens, passwords, connection secrets, `SecretText` values, security internals — must stay inside `internal` or `local` members. Public surfaces should expose only non-sensitive business data. LLMs often add a convenient `GetToken()` getter without recognizing it as a permanent security boundary breach.
## Best Practice
Keep secrets in `internal` or `local` members, and prefer the `SecretText` type so the value cannot be read back or logged. Where callers genuinely need a credential, pass it inward (a setter) rather than handing it outward (a getter). Public API should return only non-sensitive data — a masked reference, a status, a business identifier — never the raw secret. Treat each public member as a lasting commitment and keep the security-sensitive surface as small as possible.
See sample: `do-not-expose-sensitive-data-through-public-api.good.al`.
## Anti Pattern
A public `GetAccessToken()` that returns the raw token (or an event parameter carrying a credential to all subscribers), turning a secret into a de-facto public API any dependent can consume. Detection: a non-`local` procedure, event parameter, or global variable that surfaces a token, password, key, or other credential. Keep the secret internal and expose only non-sensitive data.
See sample: `do-not-expose-sensitive-data-through-public-api.bad.al`.

View file

@ -0,0 +1,22 @@
codeunit 50316 "Pricing Api Bad"
{
// Anti-pattern: new surcharge logic is added inside a procedure already marked
// obsolete, and inside a #if not CLEAN25 block. Both are scheduled for removal,
// so this behaviour disappears the moment CLEAN25 is enabled.
[Obsolete('Use GetUnitPrice instead.', '25.0')]
procedure GetPrice(ItemNo: Code[20]): Decimal
var
Price: Decimal;
begin
Price := 100;
#if not CLEAN25
Price += CalculateSurcharge(ItemNo);
#endif
exit(Price);
end;
local procedure CalculateSurcharge(ItemNo: Code[20]): Decimal
begin
exit(5);
end;
}

View file

@ -0,0 +1,26 @@
codeunit 50315 "Pricing Api Good"
{
// Obsolete member left untouched it only forwards to the replacement and
// gains no new logic.
[Obsolete('Use GetUnitPrice instead.', '25.0')]
procedure GetPrice(ItemNo: Code[20]): Decimal
begin
exit(GetUnitPrice(ItemNo));
end;
// New behaviour is built on the supported replacement, not on the obsolete member.
procedure GetUnitPrice(ItemNo: Code[20]): Decimal
begin
exit(CalculateBasePrice(ItemNo) + CalculateSurcharge(ItemNo));
end;
local procedure CalculateBasePrice(ItemNo: Code[20]): Decimal
begin
exit(100);
end;
local procedure CalculateSurcharge(ItemNo: Code[20]): Decimal
begin
exit(5);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: breaking-changes
keywords: [obsolete, clean-flag, conditional-compilation, deprecation, replacement, do-not-extend]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not build on code already marked obsolete
## Description
A member carrying `[Obsolete]`, or wrapped in a `#if not CLEANxx` conditional-compilation block, is already scheduled for deletion — the `CLEANxx` symbol is flipped on in a future release to strip that code out. Adding logic, raising new events, or taking fresh dependencies on such a member ties live behavior to something the platform is about to remove. When the deprecation completes, everything layered on top breaks. The obsolete marker is a one-way signal: it means "migrate off," never "safe to extend." LLMs frequently edit whatever procedure is nearest to the change, including obsolete ones, and add `#if not CLEANxx` branches without understanding that the block is transient.
## Best Practice
Leave obsolete members exactly as they are and implement against the current, supported replacement. New logic — a surcharge calculation, an event publisher, a hook — belongs on the live API (`GetUnitPrice`), never inside the deprecated `GetPrice` or behind a `#if not CLEAN25` guard. If the replacement does not yet exist, create it as a first-class member and build there. The obsolete code should only shrink over time, not accrete new behavior.
See sample: `do-not-modify-code-already-marked-obsolete.good.al`.
## Anti Pattern
Adding a surcharge calculation inside the `[Obsolete]` `GetPrice` procedure, or behind a `#if not CLEAN25` block, so the new behavior is wired to code that will be removed when `CLEAN25` is enabled. Detection: new statements, event declarations, or dependencies introduced inside an `[Obsolete]`-marked member or a `#if not CLEANxx` region. Move the logic onto the supported replacement instead.
See sample: `do-not-modify-code-already-marked-obsolete.bad.al`.

View file

@ -0,0 +1,11 @@
table 50311 "Customer Profile Bad"
{
fields
{
field(1; "No."; Code[20]) { }
// Breaking: the published "Email" field was renamed in place. Dependent
// extensions that reference "Email" stop compiling, and the data stored in
// the old column is orphaned on upgrade.
field(2; "Contact Email"; Text[80]) { }
}
}

View file

@ -0,0 +1,17 @@
table 50310 "Customer Profile Good"
{
fields
{
field(1; "No."; Code[20]) { }
// Replacement field shipped alongside the old one.
field(2; "Contact Email"; Text[80]) { }
// Old field kept and marked Pending so dependent code keeps compiling and
// an upgrade codeunit can copy its data before it is finally removed.
field(3; "Email"; Text[80])
{
ObsoleteState = Pending;
ObsoleteReason = 'Replaced by Contact Email. Will be removed after the deprecation window.';
ObsoleteTag = '25.0';
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: breaking-changes
keywords: [table-field, obsoletestate, obsoletereason, obsoletetag, pending, removed, data-loss]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Obsolete published table fields instead of deleting or renaming 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.
## 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.
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.
See sample: `obsolete-table-fields-instead-of-deleting-them.bad.al`.