@objectstack/plugin-approvals 17.1.0 → 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -364,11 +364,18 @@ declare const SysApprovalRequest: Omit<{
364
364
  maxAge: string;
365
365
  onlyWhen?: Record<string, string | number | boolean | {
366
366
  $in: (string | number)[];
367
+ } | {
368
+ $null: boolean;
367
369
  }> | undefined;
368
370
  } | undefined;
369
371
  ttl?: {
370
372
  field: string;
371
373
  expireAfter: string;
374
+ onlyWhen?: Record<string, string | number | boolean | {
375
+ $in: (string | number)[];
376
+ } | {
377
+ $null: boolean;
378
+ }> | undefined;
372
379
  } | undefined;
373
380
  storage?: {
374
381
  strategy: "rotation";
@@ -4566,11 +4573,18 @@ declare const SysApprovalAction: Omit<{
4566
4573
  maxAge: string;
4567
4574
  onlyWhen?: Record<string, string | number | boolean | {
4568
4575
  $in: (string | number)[];
4576
+ } | {
4577
+ $null: boolean;
4569
4578
  }> | undefined;
4570
4579
  } | undefined;
4571
4580
  ttl?: {
4572
4581
  field: string;
4573
4582
  expireAfter: string;
4583
+ onlyWhen?: Record<string, string | number | boolean | {
4584
+ $in: (string | number)[];
4585
+ } | {
4586
+ $null: boolean;
4587
+ }> | undefined;
4574
4588
  } | undefined;
4575
4589
  storage?: {
4576
4590
  strategy: "rotation";
@@ -7129,11 +7143,18 @@ declare const SysApprovalApprover: Omit<{
7129
7143
  maxAge: string;
7130
7144
  onlyWhen?: Record<string, string | number | boolean | {
7131
7145
  $in: (string | number)[];
7146
+ } | {
7147
+ $null: boolean;
7132
7148
  }> | undefined;
7133
7149
  } | undefined;
7134
7150
  ttl?: {
7135
7151
  field: string;
7136
7152
  expireAfter: string;
7153
+ onlyWhen?: Record<string, string | number | boolean | {
7154
+ $in: (string | number)[];
7155
+ } | {
7156
+ $null: boolean;
7157
+ }> | undefined;
7137
7158
  } | undefined;
7138
7159
  storage?: {
7139
7160
  strategy: "rotation";
@@ -8671,11 +8692,18 @@ declare const SysApprovalDelegation: Omit<{
8671
8692
  maxAge: string;
8672
8693
  onlyWhen?: Record<string, string | number | boolean | {
8673
8694
  $in: (string | number)[];
8695
+ } | {
8696
+ $null: boolean;
8674
8697
  }> | undefined;
8675
8698
  } | undefined;
8676
8699
  ttl?: {
8677
8700
  field: string;
8678
8701
  expireAfter: string;
8702
+ onlyWhen?: Record<string, string | number | boolean | {
8703
+ $in: (string | number)[];
8704
+ } | {
8705
+ $null: boolean;
8706
+ }> | undefined;
8679
8707
  } | undefined;
8680
8708
  storage?: {
8681
8709
  strategy: "rotation";
@@ -10443,6 +10471,64 @@ declare const SysApprovalDelegation: Omit<{
10443
10471
  };
10444
10472
  }, "fields">;
10445
10473
 
10474
+ /**
10475
+ * Read-time redaction of the approval payload snapshot (#10749).
10476
+ *
10477
+ * ## The defect this closes
10478
+ *
10479
+ * `sys_approval_request.payload_json` stores the submitted record's raw row,
10480
+ * captured from the flow's `$record` variable — which the automation layer
10481
+ * hands over with **the record's own FLS never applying**. The column is a
10482
+ * `textarea` on `sys_approval_request`, so to every read door it is an opaque
10483
+ * string: the field-visibility machinery governs *columns of objects* and
10484
+ * cannot see inside a JSON column. Every field-level read control an app
10485
+ * author declared on the SUBJECT object — `requiredPermissions` (ADR-0066 D3),
10486
+ * a permission set marking a field non-readable, a `maskingRule` — is
10487
+ * therefore unenforceable on the approval path, for every app.
10488
+ *
10489
+ * ## The shape of the fix (maintainer ruling 2026-08-22, Option B)
10490
+ *
10491
+ * The full snapshot **stays at rest**: the approval record remains audit
10492
+ * evidence of what was actually submitted. Redaction is applied at SERVE time,
10493
+ * keyed on the reading caller, so the same row answers an admin with the whole
10494
+ * snapshot and a restricted approver with only the fields they may read.
10495
+ *
10496
+ * The readable set is not recomputed here. It comes from the security
10497
+ * service's `getReadableFields(object, context)` — documented as the same
10498
+ * field mask the read middleware applies, so this seam cannot drift from
10499
+ * data-plane FLS, and the CSV/XLSX export path already derives its columns
10500
+ * from it.
10501
+ *
10502
+ * ## What this deliberately does NOT do
10503
+ *
10504
+ * - **It does not gate on OBJECT-level access.** An approver routinely has no
10505
+ * read grant on the object under approval at all — that is the normal shape
10506
+ * of an approval, and the snapshot is how they see what they are approving.
10507
+ * `getReadableFields` answers with the full field set for a caller who has
10508
+ * no field-permission entries for the object, which is exactly the behaviour
10509
+ * this seam wants: FIELD-level declarations are enforced, object-level
10510
+ * access is untouched, and every approval drawer shipping today keeps
10511
+ * rendering.
10512
+ * - **It does not act on `hidden: true`.** `hidden` is a UI contract ("Hidden
10513
+ * from default UI") and, in the spec's own words, "has never governed
10514
+ * serialization" — measurably: no read path in the repo strips a value on
10515
+ * it. Making it govern serialization here alone would make the approval path
10516
+ * stricter than a direct read of the very same row (so it would close no
10517
+ * leak — the approver can just read the record), while breaking every drawer
10518
+ * that renders a `hidden` business column. That is a `packages/spec`
10519
+ * semantics question, and it is left open rather than decided here.
10520
+ */
10521
+ /** The slice of the security service this seam consumes. */
10522
+ interface FieldVisibilitySource {
10523
+ /**
10524
+ * Fields of `object` this context may read — the same mask the ObjectQL read
10525
+ * middleware applies. `undefined` when the object schema cannot be resolved;
10526
+ * `[]` is the security plugin's own fail-closed tier (unresolvable posture,
10527
+ * dangling on-behalf-of delegator).
10528
+ */
10529
+ getReadableFields(object: string, context?: unknown): Promise<string[] | undefined>;
10530
+ }
10531
+
10446
10532
  /**
10447
10533
  * Node-era approval runtime (ADR-0019).
10448
10534
  *
@@ -10608,7 +10694,7 @@ interface ApprovalServiceOptions {
10608
10694
  clock?: ApprovalClock;
10609
10695
  logger?: {
10610
10696
  info?: (msg: any, ...rest: any[]) => void;
10611
- warn?: (msg: any, ...rest: any[]) => void;
10697
+ warn: (msg: any, ...rest: any[]) => void;
10612
10698
  error?: (msg: any, ...rest: any[]) => void;
10613
10699
  debug?: (msg: any, ...rest: any[]) => void;
10614
10700
  };
@@ -10635,6 +10721,14 @@ interface ApprovalServiceOptions {
10635
10721
  * guard stands down.
10636
10722
  */
10637
10723
  tenancyPosture?: () => string | undefined;
10724
+ /**
10725
+ * [#10749] Field-visibility authority used to redact the payload snapshot at
10726
+ * serve time. Usually attached after construction via
10727
+ * {@link ApprovalService.attachFieldVisibility}, because the security plugin
10728
+ * may register after this one. Absent ⇒ snapshots are served unredacted,
10729
+ * exactly as before this seam landed.
10730
+ */
10731
+ fieldVisibility?: FieldVisibilitySource;
10638
10732
  /**
10639
10733
  * [#8652] Objects on which a user holding READ access to the target business
10640
10734
  * record may also see that record's approval requests and action history —
@@ -10652,6 +10746,7 @@ declare class ApprovalService implements IApprovalService {
10652
10746
  private messaging?;
10653
10747
  private publicBaseUrl;
10654
10748
  private tenancyPosture?;
10749
+ private fieldVisibility?;
10655
10750
  /**
10656
10751
  * [#8652] The enabled object set for the record-reader visibility tier.
10657
10752
  * EMPTY means the tier is off — the default, and the shape every existing
@@ -10661,6 +10756,29 @@ declare class ApprovalService implements IApprovalService {
10661
10756
  constructor(opts: ApprovalServiceOptions);
10662
10757
  /** Attach (or replace) the ADR-0105 D9 posture provider. */
10663
10758
  attachTenancyPosture(provider: () => string | undefined): void;
10759
+ /**
10760
+ * [#10749] Attach (or replace) the field-visibility authority the payload
10761
+ * redaction seam reads. Late-bound: plugin load order does not guarantee the
10762
+ * security service exists when this one is constructed.
10763
+ */
10764
+ attachFieldVisibility(source: FieldVisibilitySource | undefined): void;
10765
+ /**
10766
+ * [#10749] Redact each row's payload snapshot down to the fields the READING
10767
+ * caller may see on that row's subject object.
10768
+ *
10769
+ * Runs BEFORE {@link ApprovalService.enrichRows}, and that ordering is
10770
+ * load-bearing rather than incidental: `enrichRows` derives `payload_display`
10771
+ * (lookup foreign keys inside the snapshot resolved to referenced record
10772
+ * titles) and `payload_labels` (a label per snapshot key) by WALKING THE
10773
+ * SNAPSHOT'S OWN KEYS. Redact first and both derived maps are clean for free;
10774
+ * redact after and a restricted field's name, its authored label and the
10775
+ * title of the record it points at all still ship — the value would be gone
10776
+ * and the disclosure would not.
10777
+ *
10778
+ * Rows are grouped by subject object so one `getReadableFields` call covers a
10779
+ * whole page of same-object requests.
10780
+ */
10781
+ private redactPayloads;
10664
10782
  /** Deps bundle for the ADR-0105 D9 org-scope helpers. */
10665
10783
  private get orgScopeDeps();
10666
10784
  /**
@@ -10789,8 +10907,110 @@ declare class ApprovalService implements IApprovalService {
10789
10907
  * expression's own values, pre-expansion) for the `__resolvedFrom` audit.
10790
10908
  */
10791
10909
  private resolveExpressionApprovers;
10792
- /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
10910
+ /**
10911
+ * Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy).
10912
+ *
10913
+ * Takes an organization for the reason every sibling expansion does
10914
+ * ({@link expandBusinessUnitUsers}, {@link expandPositionUsers},
10915
+ * {@link expandMembershipTierUsers}): an approver expansion answers "who, in
10916
+ * THIS organization". Before #10230 this one did not ask, and it was the last
10917
+ * expansion that did not — a `team` approver naming ANOTHER organization's
10918
+ * team routed that organization's people an approval over a record they are
10919
+ * not a tenant of.
10920
+ *
10921
+ * TWO screens run here, and they assert different things (#10230, #10547):
10922
+ *
10923
+ * 1. the TEAM must not provably belong to another organization
10924
+ * ({@link teamIsProvablyOutsideOrg}) — `sys_team` carries
10925
+ * `organization_id` outright
10926
+ * (`packages/platform-objects/src/identity/sys-team.object.ts`), so a
10927
+ * team id transitively names exactly one organization and ONE row
10928
+ * answers the question;
10929
+ * 2. each expanded MEMBER must not provably hold membership only in other
10930
+ * organizations ({@link dropMembersProvablyOutsideOrg}) —
10931
+ * `sys_team_member` carries `team_id` and `user_id` and NO tenancy
10932
+ * column at all, so passing (1) says nothing whatever about the people
10933
+ * it lists.
10934
+ *
10935
+ * #10230 landed (1) alone and deferred (2) on purpose. What closed the
10936
+ * deferral is that (1) does not imply (2) even a little: a member removed
10937
+ * from the organization but left on the team, a team re-parented across
10938
+ * organizations (`/organization/update-team` accepts `organizationId` in its
10939
+ * partial body), or a `sys_team_member` row written by a seed rather than
10940
+ * through better-auth all produce a team that passes (1) carrying a user who
10941
+ * is provably a tenant of somewhere else. Measured on this tree, not read off
10942
+ * the schema — the probe is quoted in `team-member-org-screen.test.ts`.
10943
+ *
10944
+ * (2) is the SAME assertion as {@link managerIsProvablyOutsideOrg}, one hop
10945
+ * further out, and it is asserted the same way: `sys_user` carries no tenancy
10946
+ * fact, so `sys_member` rows are the only evidence that a person is placed
10947
+ * anywhere. Like that screen, this one grants no reads and applies no read
10948
+ * screen to any approver type that lacks one today, so it decides nothing
10949
+ * #7497 (does approver routing imply record read visibility?) asks.
10950
+ */
10793
10951
  private expandTeamUsers;
10952
+ /**
10953
+ * Is `teamId` PROVABLY a team of a DIFFERENT organization? (#10230)
10954
+ *
10955
+ * "Provably" carries the same posture the sibling screen states at length in
10956
+ * {@link managerIsProvablyOutsideOrg}, for the same reasons:
10957
+ *
10958
+ * - the team row carries an `organization_id` and it is not the request's
10959
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ screen it out;
10960
+ * - the row carries no `organization_id`, does not exist, or the read failed
10961
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
10962
+ *
10963
+ * The `organization_id = null` limb is not timidity — it is the reading
10964
+ * {@link businessUnitOrgScope} settled on one screen below, for the identical
10965
+ * shape: null on a platform object means "owned by no organization", which is
10966
+ * what a seed writes because a seed cannot know the organization id the
10967
+ * runtime mints at boot. Treating null as "not mine" would delete every
10968
+ * seeded team approver at once — a larger behaviour change than the hole
10969
+ * being closed. Measured, and not hypothetically: this package's own
10970
+ * `team_ok` expansion fixture is exactly such a stack (it has
10971
+ * `sys_team_member` rows, a request carrying an organization, and no
10972
+ * `sys_team` row at all).
10973
+ *
10974
+ * Screening the TEAM before reading its members is also what keeps the cost
10975
+ * at one row: a team that fails the screen never fans out.
10976
+ */
10977
+ private teamIsProvablyOutsideOrg;
10978
+ /**
10979
+ * Drop the expanded team members who are PROVABLY tenants of other
10980
+ * organizations and not of `organizationId`. (#10547)
10981
+ *
10982
+ * Returns the survivors, in the order they were expanded.
10983
+ *
10984
+ * Posture — identical to {@link managerIsProvablyOutsideOrg} and
10985
+ * {@link teamIsProvablyOutsideOrg}, deliberately, because it is the same
10986
+ * assertion about the same table:
10987
+ *
10988
+ * - membership rows exist for this user, none in `organizationId`
10989
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ drop him;
10990
+ * - no membership rows at all for him, the read failed, or the request
10991
+ * carries no organization
10992
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
10993
+ *
10994
+ * The absent limb is load-bearing rather than timid, and #3807 is the recorded
10995
+ * cost of getting it wrong: a stack that stamps an organization on requests
10996
+ * but never materializes `sys_member` rows would otherwise lose EVERY team
10997
+ * approver at once. This package's own `team_ok` expansion fixture and
10998
+ * #10230's T2/T3 fixtures are exactly such stacks — they carry team rows and
10999
+ * a request organization and no `sys_member` table at all — so the absent
11000
+ * limb is exercised by neighbours on every run of this suite.
11001
+ *
11002
+ * ONE read for the whole slate, never one per person: the expansion is capped
11003
+ * at 10000 members and a per-user query would turn a single team approver
11004
+ * into 10000 round trips.
11005
+ *
11006
+ * ⚠️ A TRUNCATED read fails open, and that is the subtle half. This read is
11007
+ * the only evidence that a member IS a tenant here, so a result cut off at
11008
+ * the limit could be missing the very row that keeps a legitimate approver on
11009
+ * the slate — screening him out on missing evidence, which inverts the
11010
+ * posture into fail-CLOSED precisely where it must not. When the read comes
11011
+ * back at the cap it is treated as no evidence at all.
11012
+ */
11013
+ private dropMembersProvablyOutsideOrg;
10794
11014
  /**
10795
11015
  * Tenant scope for a `sys_business_unit` read that may legitimately be
10796
11016
  * env-wide (#3807).
@@ -10876,7 +11096,64 @@ declare class ApprovalService implements IApprovalService {
10876
11096
  * change here changes position routing too.
10877
11097
  */
10878
11098
  private expandMembershipTierUsers;
11099
+ /**
11100
+ * `sys_user.manager_id`, screened to the request's organization (#10153).
11101
+ *
11102
+ * Takes an organization argument for the same reason its siblings do
11103
+ * ({@link expandPositionUsers}, {@link expandMembershipTierUsers}): an
11104
+ * approver expansion answers "who, in THIS organization". Before #10153 this
11105
+ * one did not ask, and it was the only expansion that did not — a
11106
+ * `manager_id` pointing at a person in another organization routed that
11107
+ * person an approval over a record they are not a tenant of.
11108
+ *
11109
+ * ⚠️ The screen reads `sys_member`, which LOOKS like the D2 read-visibility
11110
+ * filter next to it ({@link filterApproversWhoCanRead}). It is not, and this
11111
+ * comment exists so the next reader does not conclude that #7497 (does
11112
+ * approver routing imply record read visibility?) was settled here. It was
11113
+ * not. Two facts make this the SIBLING treatment rather than a
11114
+ * read-visibility ruling:
11115
+ *
11116
+ * 1. Two of the three org-scoped expansions already screen on exactly this
11117
+ * column — `expandMembershipTierUsers` filters `sys_member.organization_id`
11118
+ * outright, and it is also the second limb of `expandPositionUsers`. So
11119
+ * `sys_member.organization_id` is already this file's answer to "which
11120
+ * organization is this person in", independent of what they may read.
11121
+ * 2. `sys_user` carries no `organization_id` at all. It is a GLOBAL identity
11122
+ * table, so a membership row is the only tenancy fact that exists for a
11123
+ * user — there is no other read this screen could have been written with.
11124
+ *
11125
+ * This change grants no reads and applies no read screen to any type that
11126
+ * lacks one today, so it decides nothing #7497 asks.
11127
+ */
10879
11128
  private lookupManager;
11129
+ /**
11130
+ * Is `managerId` PROVABLY a member of other organizations and not of
11131
+ * `organizationId`? (#10153)
11132
+ *
11133
+ * "Provably" is the whole shape of this screen, and it is deliberate rather
11134
+ * than a weaker version of "must prove membership":
11135
+ *
11136
+ * - membership rows exist for this user, none in the request's org
11137
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ screen him out;
11138
+ * - no membership rows at all, or the read failed
11139
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
11140
+ *
11141
+ * The fail-open half is not timidity, it is this file's ruled posture on
11142
+ * addressing paths, stated twice already: {@link filterApproversWhoCanRead}
11143
+ * refuses to empty a live slate on an infrastructure hiccup, and
11144
+ * {@link expandPositionUsers} carries "a step routing to nobody is worse than
11145
+ * one routing to a lapsed holder". It is also load-bearing in practice — a
11146
+ * stack that stamps an organization on its requests but does not materialize
11147
+ * `sys_member` rows would otherwise lose every manager approver at once,
11148
+ * which is a bigger behaviour change than the hole being closed. Measured:
11149
+ * this repo's own `type:manager` out-of-office fixture is such a stack.
11150
+ *
11151
+ * Screening the MANAGER only, before OOO delegation, is deliberate too: the
11152
+ * delegate arrives from `sys_approval_delegation`, whose rows already carry
11153
+ * (and are already filtered by) an `organization_id` in
11154
+ * {@link lookupActiveDelegation}. This card is about `sys_user.manager_id`.
11155
+ */
11156
+ private managerIsProvablyOutsideOrg;
10880
11157
  /**
10881
11158
  * Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
10882
11159
  * id, follow any active `sys_approval_delegation` chain and return the id the
@@ -11408,6 +11685,56 @@ declare class ApprovalService implements IApprovalService {
11408
11685
  inserted: number;
11409
11686
  deleted: number;
11410
11687
  }>;
11688
+ /**
11689
+ * [#11040] May the free-text pushdown carry an arm on the SNAPSHOT column,
11690
+ * for THIS caller over THIS query's scope?
11691
+ *
11692
+ * `payload_json` is the one searched column whose contents the serve path
11693
+ * masks per reader (`redactPayloads`, #10749). A predicate over it is
11694
+ * evaluated by the driver against the column AT REST — unmasked, before
11695
+ * anything is served — so for a caller whose view of the snapshot is masked,
11696
+ * row membership answers questions about contents that caller may not read.
11697
+ * The other four arms are columns of `sys_approval_request` itself, which
11698
+ * every caller who can see the row reads whole; they are untouched.
11699
+ *
11700
+ * ## The invariant this method exists to hold
11701
+ *
11702
+ * "This caller's view is masked" is read from **the same authority and the
11703
+ * same per-caller call as the serve path** — `resolveReadableSnapshotFields`,
11704
+ * asked as the CALLER (never `SYSTEM_CTX`). A second, independently derived
11705
+ * notion of "redacted" — comparing the readable set against the object's
11706
+ * schema, say — would be a fresh source of drift, and drift between the
11707
+ * serve rule and the filter rule IS the defect this closes, reconstituted one
11708
+ * layer down. So the only "not masked" answer accepted here is the one serve
11709
+ * itself acts on: `undefined`, the seam's documented do-not-narrow branch.
11710
+ * When the seam holds a concrete list the mask is IN FORCE, whether or not it
11711
+ * happens to remove a key from any particular row — a row-dependent question
11712
+ * no predicate can answer before rows exist.
11713
+ *
11714
+ * ## The two predicate-time cases
11715
+ *
11716
+ * Redaction is decided per ROW (each row names its own subject object, hence
11717
+ * its own readable set), but a filter is built before any row exists:
11718
+ *
11719
+ * - **authority absent** — `resolveReadableSnapshotFields` answers
11720
+ * `undefined` for EVERY object, so serve hands over every snapshot whole.
11721
+ * Keeping the arm leaks nothing serve does not already hand over, and this
11722
+ * is the shape every deployment that has not wired the security plugin
11723
+ * gets: search is byte-for-byte unchanged. Checked first, and that order
11724
+ * is load-bearing — see the object-scope note below.
11725
+ * - **authority wired** — the readable set is per object, so the arm is
11726
+ * admissible only for a scope of exactly one KNOWN object. With
11727
+ * `filter.object` present that object is known at predicate time and the
11728
+ * seam is asked about it directly. Absent, the query spans every object
11729
+ * and there is nothing sound to ask, so the arm is dropped.
11730
+ *
11731
+ * Dropping an arm is strictly NARROWING: it never refuses a query and never
11732
+ * widens what comes back, so the fail-closed direction is cheap here and is
11733
+ * taken rather than reaching for a per-object predicate machine to avoid it.
11734
+ * Refusing the query outright would be the louder behaviour change, and is
11735
+ * deliberately not what this does.
11736
+ */
11737
+ private freeTextMayMatchSnapshot;
11411
11738
  /** Filter type accepted by {@link listRequests} / {@link countRequests}. */
11412
11739
  private buildRequestWhere;
11413
11740
  /** Window the approver-index probe — pending queues live far below this. */
@@ -11641,10 +11968,45 @@ declare class ApprovalsServicePlugin implements Plugin {
11641
11968
  private service?;
11642
11969
  private engine?;
11643
11970
  private escalationJobScheduled;
11971
+ /**
11972
+ * Captured where the job is scheduled, not resolved at teardown: `destroy()`
11973
+ * — the hook the kernel actually calls — takes NO `PluginContext`
11974
+ * (`Plugin.destroy?(): Promise<void> | void`, core `types.ts`). Holding the
11975
+ * very service the schedule was placed with also means the cancel cannot miss
11976
+ * it because the registry has already been torn down around us.
11977
+ */
11978
+ private jobService?;
11644
11979
  constructor(options?: ApprovalsPluginOptions);
11645
11980
  init(ctx: PluginContext): Promise<void>;
11646
11981
  start(ctx: PluginContext): Promise<void>;
11647
- stop(ctx: PluginContext): Promise<void>;
11982
+ /**
11983
+ * The kernel's teardown hook (`Plugin.destroy?()`, core `types.ts`) — the
11984
+ * ONLY teardown entry point `ObjectKernel.performShutdown()` and
11985
+ * `LiteKernel.destroy()` invoke.
11986
+ *
11987
+ * [#10371] IT USED TO BE `stop()`, WHICH NOTHING CALLED. `Plugin` declares
11988
+ * `init()`, `start?()` and `destroy?()` and no `stop()`, so the kernel walked
11989
+ * past this plugin at shutdown: the SLA escalation job stayed scheduled and
11990
+ * this plugin's ObjectQL hooks stayed bound to an engine the kernel had
11991
+ * finished with. `start()` IS on the interface, so the pair read as symmetric
11992
+ * in review — that asymmetry is what let the same shape survive in six
11993
+ * packages at once.
11994
+ *
11995
+ * This member owns no timer of its own (the escalation clock belongs to
11996
+ * `service-job`), so it never cost a merge-queue eviction the way the
11997
+ * `plugin-reports` / `service-messaging` members did (#9371). The class is
11998
+ * the same one either way: a teardown the kernel does not reach.
11999
+ */
12000
+ destroy(): Promise<void>;
12001
+ /**
12002
+ * Retained alias for {@link destroy}. Kept because it is public API of an
12003
+ * exported class, and removing it would break an embedder who learned to call
12004
+ * it directly precisely BECAUSE the kernel never did. The parameter is now
12005
+ * optional and ignored: `destroy()` takes no context, so teardown uses the
12006
+ * job service captured when the escalation clock was wired. Prefer kernel
12007
+ * shutdown; direct callers keep working unchanged.
12008
+ */
12009
+ stop(_ctx?: PluginContext): Promise<void>;
11648
12010
  }
11649
12011
 
11650
12012
  /** Minimal surface of the automation engine this provider depends on. */