@objectstack/plugin-approvals 17.0.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.mjs CHANGED
@@ -1384,10 +1384,45 @@ var SysApprovalRequest = ObjectSchema.create({
1384
1384
  },
1385
1385
  fields: {
1386
1386
  id: Field.text({ label: "Request ID", required: true, readonly: true, group: "System" }),
1387
+ // ⚠️ MEASURED DEFECT, cloud#1395 — read this before trusting the column.
1388
+ //
1389
+ // An approval request DOES belong to an organization: it is read through the
1390
+ // organization wall by the approvals inbox, and on a shared-database
1391
+ // deployment a row carrying no organization is not filtered BY that wall —
1392
+ // it is either invisible to everyone or visible to everyone, decided by
1393
+ // whatever filter each surface happens to apply rather than by the data.
1394
+ //
1395
+ // The value is resolved from the ACTING CONTEXT only (`openNodeRequest`'s
1396
+ // `ctxOrg`), so it is NULL whenever the flow that opened the request ran
1397
+ // without one — every schedule / time-relative / api triggered run, none of
1398
+ // which sets a tenant. On a walled single-database HotCRM SaaS boot this
1399
+ // measured 27 of 27 rows org-less, each naming an `object_name` /
1400
+ // `record_id` owned by a specific customer.
1401
+ //
1402
+ // ⛔ Do NOT read that as "platform tables do not carry an organization".
1403
+ // `sys_audit_log` (1669 rows) was correctly attributed on the SAME boot,
1404
+ // because its writer takes the organization from the RECORD the row is
1405
+ // about, with the session only as fallback (plugin-audit
1406
+ // `resolveRecordOrganizationField`, #8707 honouring #8287's ruling). Two
1407
+ // writers read the actor; a third reads the subject. That disagreement is
1408
+ // the defect.
1409
+ //
1410
+ // Which of the two a side-table row should follow is an open contract
1411
+ // question on cloud#1395 — the audit resolver is scope-pinned to audit
1412
+ // stamping by the #8778 ruling, so this writer needs its own. The same
1413
+ // `ctxOrg` also stamps `sys_approval_action` and `sys_approval_approver`,
1414
+ // so all three move together.
1387
1415
  organization_id: Field.lookup("sys_organization", {
1388
1416
  label: "Organization",
1389
1417
  required: false,
1390
1418
  group: "System",
1419
+ // ⛔ String unchanged on purpose: it is extracted into the generated i18n
1420
+ // bundles (`translations/*.objects.generated.ts`, as `help`), so rewording
1421
+ // it is a translation-regeneration change and not a comment. The
1422
+ // correction it needs — it claims a propagation that measurably does not
1423
+ // happen, and says "Tenant" where ADR-0120 §Terminology requires
1424
+ // "organization" — rides the cloud#1395 write-side fix, which rewrites the
1425
+ // sentence and regenerates the four locales in one pass.
1391
1426
  description: "Tenant that owns this approval request (propagated from submitter context)"
1392
1427
  }),
1393
1428
  process_name: Field.text({
@@ -1531,6 +1566,28 @@ var SysApprovalRequest = ObjectSchema.create({
1531
1566
  // approving, rejecting, or reassigning it to a real approver. `viewer` is
1532
1567
  // attached by getRequest/listRequests; where it is absent the predicate fails
1533
1568
  // closed.
1569
+ //
1570
+ // Every predicate below is guarded for the SPARSE action face (#8990). This
1571
+ // binding is a list row or a record read carrying only what the caller
1572
+ // projected, and CEL aborts the whole expression at key resolution — so the
1573
+ // unguarded `record.viewer.can_act` faulted (`No such key: viewer`) on any
1574
+ // row without the block, and the button silently vanished, indistinguishable
1575
+ // from "the gate said no". `materializeDeclaredFields`'s doc comment in
1576
+ // `@objectstack/objectql` is the canonical statement of the guard rule; this
1577
+ // file follows it and does not restate it.
1578
+ //
1579
+ // `viewer` is a NESTED block, which needs one measurement the canonical rule
1580
+ // does not spell out. Measured against the `@objectstack/formula` CEL engine:
1581
+ // `has(record.viewer) && record.viewer != null && record.viewer.can_act`
1582
+ // still FAULTS on `{viewer: {}}` (`No such key: can_act`) and on
1583
+ // `{viewer: {can_act: null}}` (`Logical operator requires bool operands`).
1584
+ // Guarding the LEAF instead — `has(record.viewer) &&
1585
+ // has(record.viewer.can_act) && record.viewer.can_act == true` — is total
1586
+ // over every binding AND subsumes the parent `!= null` half, because `has()`
1587
+ // on a path whose parent is null answers `false` rather than faulting. So the
1588
+ // leaf `has()` plus the `== true` comparison is the MINIMAL safe form here,
1589
+ // not a longer one: `== true` is load-bearing (a bare truthy read of a null
1590
+ // leaf faults the logical operator), the parent `!= null` is not.
1534
1591
  actions: [
1535
1592
  {
1536
1593
  name: "approval_approve",
@@ -1550,7 +1607,7 @@ var SysApprovalRequest = ObjectSchema.create({
1550
1607
  // string[]`; the decision route persists them on `sys_approval_action`.
1551
1608
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1552
1609
  ],
1553
- visible: "record.viewer.can_act || record.viewer.can_override",
1610
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true",
1554
1611
  locations: ["record_section", "list_item"],
1555
1612
  successMessage: "Approved.",
1556
1613
  refreshAfter: true
@@ -1578,7 +1635,7 @@ var SysApprovalRequest = ObjectSchema.create({
1578
1635
  { name: "comment", label: "Comment", type: "textarea", required: false },
1579
1636
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1580
1637
  ],
1581
- visible: "record.viewer.can_act || record.viewer.can_override",
1638
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true",
1582
1639
  locations: ["record_section", "list_item"],
1583
1640
  successMessage: "Rejected.",
1584
1641
  refreshAfter: true
@@ -1599,7 +1656,7 @@ var SysApprovalRequest = ObjectSchema.create({
1599
1656
  { field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
1600
1657
  { name: "comment", label: "Comment", type: "textarea", required: false }
1601
1658
  ],
1602
- visible: "record.viewer.can_act || record.viewer.can_override",
1659
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true",
1603
1660
  locations: ["record_section"],
1604
1661
  successMessage: "Reassigned.",
1605
1662
  refreshAfter: true
@@ -1618,7 +1675,7 @@ var SysApprovalRequest = ObjectSchema.create({
1618
1675
  params: [
1619
1676
  { name: "comment", label: "Reason", type: "textarea", required: false }
1620
1677
  ],
1621
- visible: "record.viewer.can_act",
1678
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true",
1622
1679
  locations: ["record_section"],
1623
1680
  successMessage: "Sent back for revision.",
1624
1681
  refreshAfter: true
@@ -1633,7 +1690,7 @@ var SysApprovalRequest = ObjectSchema.create({
1633
1690
  params: [
1634
1691
  { name: "comment", label: "What do you need?", type: "textarea", required: true }
1635
1692
  ],
1636
- visible: "record.viewer.can_act",
1693
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true",
1637
1694
  locations: ["record_section"],
1638
1695
  successMessage: "Information requested.",
1639
1696
  refreshAfter: true
@@ -1654,7 +1711,7 @@ var SysApprovalRequest = ObjectSchema.create({
1654
1711
  params: [
1655
1712
  { name: "comment", label: "Note", type: "textarea", required: false }
1656
1713
  ],
1657
- visible: 'record.status == "pending" && record.viewer.is_submitter',
1714
+ visible: 'has(record.status) && record.status == "pending" && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
1658
1715
  locations: ["record_section"],
1659
1716
  successMessage: "Reminder sent.",
1660
1717
  refreshAfter: true
@@ -1674,7 +1731,7 @@ var SysApprovalRequest = ObjectSchema.create({
1674
1731
  ],
1675
1732
  // Recall applies while the request is live for the submitter — pending
1676
1733
  // (withdraw) or returned (abandon the revision instead of resubmitting).
1677
- visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
1734
+ visible: 'has(record.status) && (record.status == "pending" || record.status == "returned") && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
1678
1735
  locations: ["record_section"],
1679
1736
  successMessage: "Recalled.",
1680
1737
  refreshAfter: true
@@ -1689,7 +1746,7 @@ var SysApprovalRequest = ObjectSchema.create({
1689
1746
  params: [
1690
1747
  { name: "comment", label: "What changed?", type: "textarea", required: false }
1691
1748
  ],
1692
- visible: 'record.status == "returned" && record.viewer.is_submitter',
1749
+ visible: 'has(record.status) && record.status == "returned" && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
1693
1750
  locations: ["record_section"],
1694
1751
  successMessage: "Resubmitted.",
1695
1752
  refreshAfter: true
@@ -2163,6 +2220,38 @@ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
2163
2220
  return userIds.filter((u) => canRead.has(u));
2164
2221
  }
2165
2222
 
2223
+ // src/payload-redaction.ts
2224
+ function redactSnapshot(payload, readable) {
2225
+ if (readable === void 0) return { payload, redactedKeys: [] };
2226
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
2227
+ return { payload, redactedKeys: [] };
2228
+ }
2229
+ const allowed = new Set(readable.map((f) => String(f)));
2230
+ const source = payload;
2231
+ const redactedKeys = [];
2232
+ const kept = {};
2233
+ for (const key of Object.keys(source)) {
2234
+ if (allowed.has(key)) kept[key] = source[key];
2235
+ else redactedKeys.push(key);
2236
+ }
2237
+ if (redactedKeys.length === 0) return { payload, redactedKeys: [] };
2238
+ return { payload: kept, redactedKeys: redactedKeys.sort() };
2239
+ }
2240
+ async function resolveReadableSnapshotFields(security, objectName, context, logger) {
2241
+ if (!security || typeof security.getReadableFields !== "function") return void 0;
2242
+ const object = String(objectName ?? "").trim();
2243
+ if (!object) return void 0;
2244
+ try {
2245
+ return await security.getReadableFields(object, context);
2246
+ } catch (err) {
2247
+ logger?.warn?.("[approvals] payload redaction could not resolve readable fields \u2014 serving the snapshot unredacted", {
2248
+ object,
2249
+ error: err?.message ?? String(err)
2250
+ });
2251
+ return void 0;
2252
+ }
2253
+ }
2254
+
2166
2255
  // src/approval-service.ts
2167
2256
  var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
2168
2257
  var ESCALATION_JOB_NAME = "approvals-sla-escalation";
@@ -2183,6 +2272,7 @@ function actingUserId(context) {
2183
2272
  return typeof userId === "string" && userId ? userId : null;
2184
2273
  }
2185
2274
  var OOO_MAX_CHAIN = 8;
2275
+ var MEMBER_SCREEN_READ_LIMIT = 5e4;
2186
2276
  var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
2187
2277
  "team",
2188
2278
  "department",
@@ -2335,11 +2425,66 @@ var _ApprovalService = class _ApprovalService {
2335
2425
  this.messaging = opts.messaging;
2336
2426
  this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
2337
2427
  this.tenancyPosture = opts.tenancyPosture;
2428
+ this.fieldVisibility = opts.fieldVisibility;
2429
+ this.recordReaderVisibleObjects = new Set(
2430
+ (Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
2431
+ );
2338
2432
  }
2339
2433
  /** Attach (or replace) the ADR-0105 D9 posture provider. */
2340
2434
  attachTenancyPosture(provider) {
2341
2435
  this.tenancyPosture = provider;
2342
2436
  }
2437
+ /**
2438
+ * [#10749] Attach (or replace) the field-visibility authority the payload
2439
+ * redaction seam reads. Late-bound: plugin load order does not guarantee the
2440
+ * security service exists when this one is constructed.
2441
+ */
2442
+ attachFieldVisibility(source) {
2443
+ this.fieldVisibility = source;
2444
+ }
2445
+ /**
2446
+ * [#10749] Redact each row's payload snapshot down to the fields the READING
2447
+ * caller may see on that row's subject object.
2448
+ *
2449
+ * Runs BEFORE {@link ApprovalService.enrichRows}, and that ordering is
2450
+ * load-bearing rather than incidental: `enrichRows` derives `payload_display`
2451
+ * (lookup foreign keys inside the snapshot resolved to referenced record
2452
+ * titles) and `payload_labels` (a label per snapshot key) by WALKING THE
2453
+ * SNAPSHOT'S OWN KEYS. Redact first and both derived maps are clean for free;
2454
+ * redact after and a restricted field's name, its authored label and the
2455
+ * title of the record it points at all still ship — the value would be gone
2456
+ * and the disclosure would not.
2457
+ *
2458
+ * Rows are grouped by subject object so one `getReadableFields` call covers a
2459
+ * whole page of same-object requests.
2460
+ */
2461
+ async redactPayloads(rows, context) {
2462
+ const withPayload = rows.filter((r) => r?.payload != null);
2463
+ if (withPayload.length === 0) return;
2464
+ const byObject = /* @__PURE__ */ new Map();
2465
+ for (const r of withPayload) {
2466
+ const key = String(r.object_name ?? "");
2467
+ let list = byObject.get(key);
2468
+ if (!list) {
2469
+ list = [];
2470
+ byObject.set(key, list);
2471
+ }
2472
+ list.push(r);
2473
+ }
2474
+ for (const [object, group] of byObject) {
2475
+ const readable = await resolveReadableSnapshotFields(
2476
+ this.fieldVisibility,
2477
+ object,
2478
+ context,
2479
+ this.logger
2480
+ );
2481
+ if (readable === void 0) continue;
2482
+ for (const r of group) {
2483
+ const { payload } = redactSnapshot(r.payload, readable);
2484
+ r.payload = payload;
2485
+ }
2486
+ }
2487
+ }
2343
2488
  /** Deps bundle for the ADR-0105 D9 org-scope helpers. */
2344
2489
  get orgScopeDeps() {
2345
2490
  return {
@@ -2606,7 +2751,7 @@ var _ApprovalService = class _ApprovalService {
2606
2751
  }) : users;
2607
2752
  try {
2608
2753
  if (type === "team") {
2609
- const users = await this.expandTeamUsers(String(a.value));
2754
+ const users = await this.expandTeamUsers(String(a.value), organizationId);
2610
2755
  if (users.length) return users;
2611
2756
  } else if (type === "department" || type === "business_unit" || type === "bu") {
2612
2757
  const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
@@ -2620,7 +2765,7 @@ var _ApprovalService = class _ApprovalService {
2620
2765
  } else if (type === "manager" && record) {
2621
2766
  const subject = record[a.value] ?? record.owner_id;
2622
2767
  if (subject) {
2623
- const mgr = await this.lookupManager(String(subject));
2768
+ const mgr = await this.lookupManager(String(subject), organizationId);
2624
2769
  if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
2625
2770
  }
2626
2771
  }
@@ -2717,7 +2862,7 @@ var _ApprovalService = class _ApprovalService {
2717
2862
  try {
2718
2863
  if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
2719
2864
  else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
2720
- else if (resolveAs === "team") users = await this.expandTeamUsers(key);
2865
+ else if (resolveAs === "team") users = await this.expandTeamUsers(key, directoryOrg);
2721
2866
  else {
2722
2867
  throw new Error(
2723
2868
  `VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
@@ -2742,9 +2887,50 @@ var _ApprovalService = class _ApprovalService {
2742
2887
  }
2743
2888
  return { slots, raw };
2744
2889
  }
2745
- /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
2746
- async expandTeamUsers(teamId) {
2890
+ /**
2891
+ * Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy).
2892
+ *
2893
+ * Takes an organization for the reason every sibling expansion does
2894
+ * ({@link expandBusinessUnitUsers}, {@link expandPositionUsers},
2895
+ * {@link expandMembershipTierUsers}): an approver expansion answers "who, in
2896
+ * THIS organization". Before #10230 this one did not ask, and it was the last
2897
+ * expansion that did not — a `team` approver naming ANOTHER organization's
2898
+ * team routed that organization's people an approval over a record they are
2899
+ * not a tenant of.
2900
+ *
2901
+ * TWO screens run here, and they assert different things (#10230, #10547):
2902
+ *
2903
+ * 1. the TEAM must not provably belong to another organization
2904
+ * ({@link teamIsProvablyOutsideOrg}) — `sys_team` carries
2905
+ * `organization_id` outright
2906
+ * (`packages/platform-objects/src/identity/sys-team.object.ts`), so a
2907
+ * team id transitively names exactly one organization and ONE row
2908
+ * answers the question;
2909
+ * 2. each expanded MEMBER must not provably hold membership only in other
2910
+ * organizations ({@link dropMembersProvablyOutsideOrg}) —
2911
+ * `sys_team_member` carries `team_id` and `user_id` and NO tenancy
2912
+ * column at all, so passing (1) says nothing whatever about the people
2913
+ * it lists.
2914
+ *
2915
+ * #10230 landed (1) alone and deferred (2) on purpose. What closed the
2916
+ * deferral is that (1) does not imply (2) even a little: a member removed
2917
+ * from the organization but left on the team, a team re-parented across
2918
+ * organizations (`/organization/update-team` accepts `organizationId` in its
2919
+ * partial body), or a `sys_team_member` row written by a seed rather than
2920
+ * through better-auth all produce a team that passes (1) carrying a user who
2921
+ * is provably a tenant of somewhere else. Measured on this tree, not read off
2922
+ * the schema — the probe is quoted in `team-member-org-screen.test.ts`.
2923
+ *
2924
+ * (2) is the SAME assertion as {@link managerIsProvablyOutsideOrg}, one hop
2925
+ * further out, and it is asserted the same way: `sys_user` carries no tenancy
2926
+ * fact, so `sys_member` rows are the only evidence that a person is placed
2927
+ * anywhere. Like that screen, this one grants no reads and applies no read
2928
+ * screen to any approver type that lacks one today, so it decides nothing
2929
+ * #7497 (does approver routing imply record read visibility?) asks.
2930
+ */
2931
+ async expandTeamUsers(teamId, organizationId) {
2747
2932
  if (!teamId) return [];
2933
+ if (await this.teamIsProvablyOutsideOrg(teamId, organizationId)) return [];
2748
2934
  let rows = [];
2749
2935
  try {
2750
2936
  rows = await this.engine.find("sys_team_member", {
@@ -2756,7 +2942,149 @@ var _ApprovalService = class _ApprovalService {
2756
2942
  } catch {
2757
2943
  rows = [];
2758
2944
  }
2759
- return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
2945
+ const users = Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
2946
+ return await this.dropMembersProvablyOutsideOrg(teamId, users, organizationId);
2947
+ }
2948
+ /**
2949
+ * Is `teamId` PROVABLY a team of a DIFFERENT organization? (#10230)
2950
+ *
2951
+ * "Provably" carries the same posture the sibling screen states at length in
2952
+ * {@link managerIsProvablyOutsideOrg}, for the same reasons:
2953
+ *
2954
+ * - the team row carries an `organization_id` and it is not the request's
2955
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ screen it out;
2956
+ * - the row carries no `organization_id`, does not exist, or the read failed
2957
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
2958
+ *
2959
+ * The `organization_id = null` limb is not timidity — it is the reading
2960
+ * {@link businessUnitOrgScope} settled on one screen below, for the identical
2961
+ * shape: null on a platform object means "owned by no organization", which is
2962
+ * what a seed writes because a seed cannot know the organization id the
2963
+ * runtime mints at boot. Treating null as "not mine" would delete every
2964
+ * seeded team approver at once — a larger behaviour change than the hole
2965
+ * being closed. Measured, and not hypothetically: this package's own
2966
+ * `team_ok` expansion fixture is exactly such a stack (it has
2967
+ * `sys_team_member` rows, a request carrying an organization, and no
2968
+ * `sys_team` row at all).
2969
+ *
2970
+ * Screening the TEAM before reading its members is also what keeps the cost
2971
+ * at one row: a team that fails the screen never fans out.
2972
+ */
2973
+ async teamIsProvablyOutsideOrg(teamId, organizationId) {
2974
+ const requestOrg = organizationId ? String(organizationId) : "";
2975
+ if (!requestOrg) return false;
2976
+ let rows = [];
2977
+ try {
2978
+ rows = await this.engine.find("sys_team", {
2979
+ where: { id: teamId },
2980
+ fields: ["id", "organization_id"],
2981
+ limit: 1,
2982
+ context: SYSTEM_CTX2
2983
+ });
2984
+ } catch {
2985
+ return false;
2986
+ }
2987
+ const row = Array.isArray(rows) ? rows[0] : null;
2988
+ const teamOrg = row?.organization_id ? String(row.organization_id) : "";
2989
+ if (!teamOrg) return false;
2990
+ if (teamOrg === requestOrg) return false;
2991
+ this.logger?.warn?.(
2992
+ `[approvals] #10230: team '${teamId}' was dropped from the approver slate \u2014 'sys_team.organization_id' is '${teamOrg}', not the request's organization '${requestOrg}', so routing this approval to its members would put approval authority over the record outside its tenant. Point the approver at a team in this organization, or route this step with an approver type that names someone in it.`,
2993
+ { teamId, teamOrganizationId: teamOrg, requestOrganizationId: requestOrg }
2994
+ );
2995
+ return true;
2996
+ }
2997
+ /**
2998
+ * Drop the expanded team members who are PROVABLY tenants of other
2999
+ * organizations and not of `organizationId`. (#10547)
3000
+ *
3001
+ * Returns the survivors, in the order they were expanded.
3002
+ *
3003
+ * Posture — identical to {@link managerIsProvablyOutsideOrg} and
3004
+ * {@link teamIsProvablyOutsideOrg}, deliberately, because it is the same
3005
+ * assertion about the same table:
3006
+ *
3007
+ * - membership rows exist for this user, none in `organizationId`
3008
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ drop him;
3009
+ * - no membership rows at all for him, the read failed, or the request
3010
+ * carries no organization
3011
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
3012
+ *
3013
+ * The absent limb is load-bearing rather than timid, and #3807 is the recorded
3014
+ * cost of getting it wrong: a stack that stamps an organization on requests
3015
+ * but never materializes `sys_member` rows would otherwise lose EVERY team
3016
+ * approver at once. This package's own `team_ok` expansion fixture and
3017
+ * #10230's T2/T3 fixtures are exactly such stacks — they carry team rows and
3018
+ * a request organization and no `sys_member` table at all — so the absent
3019
+ * limb is exercised by neighbours on every run of this suite.
3020
+ *
3021
+ * ONE read for the whole slate, never one per person: the expansion is capped
3022
+ * at 10000 members and a per-user query would turn a single team approver
3023
+ * into 10000 round trips.
3024
+ *
3025
+ * ⚠️ A TRUNCATED read fails open, and that is the subtle half. This read is
3026
+ * the only evidence that a member IS a tenant here, so a result cut off at
3027
+ * the limit could be missing the very row that keeps a legitimate approver on
3028
+ * the slate — screening him out on missing evidence, which inverts the
3029
+ * posture into fail-CLOSED precisely where it must not. When the read comes
3030
+ * back at the cap it is treated as no evidence at all.
3031
+ */
3032
+ async dropMembersProvablyOutsideOrg(teamId, userIds, organizationId) {
3033
+ const requestOrg = organizationId ? String(organizationId) : "";
3034
+ if (!requestOrg || !userIds.length) return userIds;
3035
+ let rows = [];
3036
+ try {
3037
+ rows = await this.engine.find("sys_member", {
3038
+ where: { user_id: { $in: userIds } },
3039
+ fields: ["user_id", "organization_id"],
3040
+ limit: MEMBER_SCREEN_READ_LIMIT,
3041
+ context: SYSTEM_CTX2
3042
+ });
3043
+ } catch {
3044
+ return userIds;
3045
+ }
3046
+ if ((rows?.length ?? 0) >= MEMBER_SCREEN_READ_LIMIT) {
3047
+ this.logger?.warn?.(
3048
+ `[approvals] #10547: the membership screen for team '${teamId}' read ${rows.length} 'sys_member' rows, at or above its ${MEMBER_SCREEN_READ_LIMIT}-row cap, so the result may be truncated. Routing is left unchanged rather than risk dropping a member whose proof of membership fell outside the read.`,
3049
+ { teamId, requestOrganizationId: requestOrg, rowsRead: rows.length }
3050
+ );
3051
+ return userIds;
3052
+ }
3053
+ const orgsByUser = /* @__PURE__ */ new Map();
3054
+ for (const r of rows ?? []) {
3055
+ const uid2 = String(r?.user_id ?? "");
3056
+ const org = String(r?.organization_id ?? "");
3057
+ if (!uid2 || !org) continue;
3058
+ const seen = orgsByUser.get(uid2);
3059
+ if (seen) seen.push(org);
3060
+ else orgsByUser.set(uid2, [org]);
3061
+ }
3062
+ const kept = [];
3063
+ const dropped = [];
3064
+ for (const uid2 of userIds) {
3065
+ const orgs = orgsByUser.get(uid2);
3066
+ if (!orgs?.length) {
3067
+ kept.push(uid2);
3068
+ continue;
3069
+ }
3070
+ if (orgs.includes(requestOrg)) {
3071
+ kept.push(uid2);
3072
+ continue;
3073
+ }
3074
+ dropped.push({ userId: uid2, organizationIds: orgs });
3075
+ }
3076
+ if (dropped.length) {
3077
+ this.logger?.warn?.(
3078
+ `[approvals] #10547: ${dropped.length} member(s) of team '${teamId}' were dropped from the approver slate \u2014 ${dropped.map((d) => `'${d.userId}'`).join(", ")} hold membership in other organization(s), none of them the request's organization '${requestOrg}', so routing this approval to them would put approval authority over the record outside its tenant. The TEAM itself belongs to this organization; its 'sys_team_member' rows carry no organization of their own. Remove them from the team, grant them a membership in this organization, or route this step with an approver type that names someone in it.`,
3079
+ {
3080
+ teamId,
3081
+ requestOrganizationId: requestOrg,
3082
+ droppedUserIds: dropped.map((d) => d.userId),
3083
+ droppedMemberOrganizationIds: dropped.map((d) => d.organizationIds)
3084
+ }
3085
+ );
3086
+ }
3087
+ return kept;
2760
3088
  }
2761
3089
  /**
2762
3090
  * Tenant scope for a `sys_business_unit` read that may legitimately be
@@ -2836,9 +3164,48 @@ var _ApprovalService = class _ApprovalService {
2836
3164
  * Position holders (ADR-0090 D3): `sys_user_position` is the platform-owned
2837
3165
  * assignment table, keyed by the position's machine name (ADR-0057 D4),
2838
3166
  * unioned with the better-auth membership string (`sys_member.role`) as a
2839
- * transition source — the same semantics as `PositionGraphService` in
2840
- * `plugin-sharing`, so an approval routes to exactly the users the sharing
2841
- * engine would expand for the same position.
3167
+ * transition source.
3168
+ *
3169
+ * ⚠️ This is a ROUTING read (approver slates and escalation targets), and it
3170
+ * is deliberately NOT the same read as `PositionGraphService` in
3171
+ * `plugin-sharing`, whatever the shared method name suggests. Both answer
3172
+ * "who holds position P"; this one reads the directory RAW — neither the
3173
+ * ADR-0091 D2 validity window nor the `sys_position.active` catalogue flag is
3174
+ * applied. Maintainer ruling, 2026-08-15 (#8710, inheriting #8613), verbatim:
3175
+ *
3176
+ * > Access-conferring paths filter deactivated positions; addressing paths
3177
+ * > do not.
3178
+ *
3179
+ * Routing is an addressing path, so dropping a holder here is fail-OPEN, not
3180
+ * fail-closed: an expansion that comes back empty does not narrow the slate,
3181
+ * it falls through to the literal `position:` slot no user can ever act on —
3182
+ * the permanently stuck request of #3807 / #3424. A step routing to nobody is
3183
+ * worse than one routing to a lapsed holder, so the lapsed holder stays.
3184
+ *
3185
+ * Where the two implementations actually stand, per source. Both limbs are
3186
+ * listed because a statement about one of them is not a statement about this
3187
+ * method:
3188
+ *
3189
+ * 1. `sys_user_position` — sharing projects `valid_from` / `valid_until` and
3190
+ * drops rows on `isGrantActive` inside its own helper; we project
3191
+ * `user_id` alone, so an assignment that expired last month still routes.
3192
+ * This is the one real divergence, and it is the intended one.
3193
+ * 2. `sys_member.role` — raw on BOTH sides (`TeamGraphService.expandRoleUsers`
3194
+ * projects `user_id` too). The table carries no window columns at all and
3195
+ * `isGrantActive` reads an absent bound as unbounded, so there is nothing
3196
+ * a filter could do here; membership tier names have no `sys_position`
3197
+ * row either (#8710's "a name with no row is untouched" fallback), so no
3198
+ * catalogue flag either. This limb cannot be brought into parity by
3199
+ * adding a filter — see {@link expandMembershipTierUsers}.
3200
+ * 3. `sys_position.active` — the sharing engine's gate for it lives at the
3201
+ * RULE EVALUATOR's call site (`positionConfersAccess` in
3202
+ * `sharing-rule-service.ts`), not inside `PositionGraphService`; the same
3203
+ * ruling gives it no counterpart on this path.
3204
+ *
3205
+ * The omission is per-READ, not a missing dependency: `isGrantActive` is
3206
+ * imported in this file and IS applied to `sys_approval_delegation` in
3207
+ * {@link lookupActiveDelegation}. ⛔ So do not "fix" this by adding the window
3208
+ * filter here — that is the option #8710 rejected, on the reasoning above.
2842
3209
  */
2843
3210
  async expandPositionUsers(positionName, organizationId) {
2844
3211
  if (!positionName) return [];
@@ -2866,6 +3233,13 @@ var _ApprovalService = class _ApprovalService {
2866
3233
  * NOT positions. Named for the projection (`org_membership_level`, ADR-0057
2867
3234
  * D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
2868
3235
  * and stays, the platform-facing word does not.
3236
+ *
3237
+ * Read RAW, like every routing read here, and with nothing available to
3238
+ * filter even if it were not: `sys_member` carries no ADR-0091 D2 window
3239
+ * columns, and a tier name has no `sys_position` row to read `active` off.
3240
+ * {@link expandPositionUsers} carries the ruling both reads inherit
3241
+ * (#8613 / #8710) — this method is also the second limb of that union, so a
3242
+ * change here changes position routing too.
2869
3243
  */
2870
3244
  async expandMembershipTierUsers(tier, organizationId) {
2871
3245
  if (!tier) return [];
@@ -2879,7 +3253,36 @@ var _ApprovalService = class _ApprovalService {
2879
3253
  }
2880
3254
  return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
2881
3255
  }
2882
- async lookupManager(userId) {
3256
+ /**
3257
+ * `sys_user.manager_id`, screened to the request's organization (#10153).
3258
+ *
3259
+ * Takes an organization argument for the same reason its siblings do
3260
+ * ({@link expandPositionUsers}, {@link expandMembershipTierUsers}): an
3261
+ * approver expansion answers "who, in THIS organization". Before #10153 this
3262
+ * one did not ask, and it was the only expansion that did not — a
3263
+ * `manager_id` pointing at a person in another organization routed that
3264
+ * person an approval over a record they are not a tenant of.
3265
+ *
3266
+ * ⚠️ The screen reads `sys_member`, which LOOKS like the D2 read-visibility
3267
+ * filter next to it ({@link filterApproversWhoCanRead}). It is not, and this
3268
+ * comment exists so the next reader does not conclude that #7497 (does
3269
+ * approver routing imply record read visibility?) was settled here. It was
3270
+ * not. Two facts make this the SIBLING treatment rather than a
3271
+ * read-visibility ruling:
3272
+ *
3273
+ * 1. Two of the three org-scoped expansions already screen on exactly this
3274
+ * column — `expandMembershipTierUsers` filters `sys_member.organization_id`
3275
+ * outright, and it is also the second limb of `expandPositionUsers`. So
3276
+ * `sys_member.organization_id` is already this file's answer to "which
3277
+ * organization is this person in", independent of what they may read.
3278
+ * 2. `sys_user` carries no `organization_id` at all. It is a GLOBAL identity
3279
+ * table, so a membership row is the only tenancy fact that exists for a
3280
+ * user — there is no other read this screen could have been written with.
3281
+ *
3282
+ * This change grants no reads and applies no read screen to any type that
3283
+ * lacks one today, so it decides nothing #7497 asks.
3284
+ */
3285
+ async lookupManager(userId, organizationId) {
2883
3286
  try {
2884
3287
  const rows = await this.engine.find("sys_user", {
2885
3288
  where: { id: userId },
@@ -2888,11 +3291,64 @@ var _ApprovalService = class _ApprovalService {
2888
3291
  context: SYSTEM_CTX2
2889
3292
  });
2890
3293
  const row = Array.isArray(rows) ? rows[0] : null;
2891
- return row?.manager_id ? String(row.manager_id) : null;
3294
+ const managerId = row?.manager_id ? String(row.manager_id) : null;
3295
+ if (!managerId) return null;
3296
+ if (await this.managerIsProvablyOutsideOrg(managerId, organizationId)) return null;
3297
+ return managerId;
2892
3298
  } catch {
2893
3299
  return null;
2894
3300
  }
2895
3301
  }
3302
+ /**
3303
+ * Is `managerId` PROVABLY a member of other organizations and not of
3304
+ * `organizationId`? (#10153)
3305
+ *
3306
+ * "Provably" is the whole shape of this screen, and it is deliberate rather
3307
+ * than a weaker version of "must prove membership":
3308
+ *
3309
+ * - membership rows exist for this user, none in the request's org
3310
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ screen him out;
3311
+ * - no membership rows at all, or the read failed
3312
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
3313
+ *
3314
+ * The fail-open half is not timidity, it is this file's ruled posture on
3315
+ * addressing paths, stated twice already: {@link filterApproversWhoCanRead}
3316
+ * refuses to empty a live slate on an infrastructure hiccup, and
3317
+ * {@link expandPositionUsers} carries "a step routing to nobody is worse than
3318
+ * one routing to a lapsed holder". It is also load-bearing in practice — a
3319
+ * stack that stamps an organization on its requests but does not materialize
3320
+ * `sys_member` rows would otherwise lose every manager approver at once,
3321
+ * which is a bigger behaviour change than the hole being closed. Measured:
3322
+ * this repo's own `type:manager` out-of-office fixture is such a stack.
3323
+ *
3324
+ * Screening the MANAGER only, before OOO delegation, is deliberate too: the
3325
+ * delegate arrives from `sys_approval_delegation`, whose rows already carry
3326
+ * (and are already filtered by) an `organization_id` in
3327
+ * {@link lookupActiveDelegation}. This card is about `sys_user.manager_id`.
3328
+ */
3329
+ async managerIsProvablyOutsideOrg(managerId, organizationId) {
3330
+ const requestOrg = organizationId ? String(organizationId) : "";
3331
+ if (!requestOrg) return false;
3332
+ let rows = [];
3333
+ try {
3334
+ rows = await this.engine.find("sys_member", {
3335
+ where: { user_id: managerId },
3336
+ fields: ["user_id", "organization_id"],
3337
+ limit: 1e3,
3338
+ context: SYSTEM_CTX2
3339
+ });
3340
+ } catch {
3341
+ return false;
3342
+ }
3343
+ const orgs = (rows ?? []).map((r) => String(r?.organization_id ?? "")).filter(Boolean);
3344
+ if (!orgs.length) return false;
3345
+ if (orgs.includes(requestOrg)) return false;
3346
+ this.logger?.warn?.(
3347
+ `[approvals] #10153: manager '${managerId}' was dropped from the approver slate \u2014 'sys_user.manager_id' points across an organization boundary. He holds membership in ${orgs.length} organization(s), none of them the request's organization '${requestOrg}', so routing this approval to him would put approval authority over the record outside its tenant. Fix the 'manager_id' link, grant him a membership in this organization, or route this step with an approver type that names someone in it.`,
3348
+ { managerId, requestOrganizationId: requestOrg, managerOrganizationIds: orgs }
3349
+ );
3350
+ return true;
3351
+ }
2896
3352
  /**
2897
3353
  * Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
2898
3354
  * id, follow any active `sys_approval_delegation` chain and return the id the
@@ -5018,8 +5474,70 @@ var _ApprovalService = class _ApprovalService {
5018
5474
  return { requests: desired.size, inserted, deleted };
5019
5475
  }
5020
5476
  // ── Read API ─────────────────────────────────────────────────
5477
+ /**
5478
+ * [#11040] May the free-text pushdown carry an arm on the SNAPSHOT column,
5479
+ * for THIS caller over THIS query's scope?
5480
+ *
5481
+ * `payload_json` is the one searched column whose contents the serve path
5482
+ * masks per reader (`redactPayloads`, #10749). A predicate over it is
5483
+ * evaluated by the driver against the column AT REST — unmasked, before
5484
+ * anything is served — so for a caller whose view of the snapshot is masked,
5485
+ * row membership answers questions about contents that caller may not read.
5486
+ * The other four arms are columns of `sys_approval_request` itself, which
5487
+ * every caller who can see the row reads whole; they are untouched.
5488
+ *
5489
+ * ## The invariant this method exists to hold
5490
+ *
5491
+ * "This caller's view is masked" is read from **the same authority and the
5492
+ * same per-caller call as the serve path** — `resolveReadableSnapshotFields`,
5493
+ * asked as the CALLER (never `SYSTEM_CTX`). A second, independently derived
5494
+ * notion of "redacted" — comparing the readable set against the object's
5495
+ * schema, say — would be a fresh source of drift, and drift between the
5496
+ * serve rule and the filter rule IS the defect this closes, reconstituted one
5497
+ * layer down. So the only "not masked" answer accepted here is the one serve
5498
+ * itself acts on: `undefined`, the seam's documented do-not-narrow branch.
5499
+ * When the seam holds a concrete list the mask is IN FORCE, whether or not it
5500
+ * happens to remove a key from any particular row — a row-dependent question
5501
+ * no predicate can answer before rows exist.
5502
+ *
5503
+ * ## The two predicate-time cases
5504
+ *
5505
+ * Redaction is decided per ROW (each row names its own subject object, hence
5506
+ * its own readable set), but a filter is built before any row exists:
5507
+ *
5508
+ * - **authority absent** — `resolveReadableSnapshotFields` answers
5509
+ * `undefined` for EVERY object, so serve hands over every snapshot whole.
5510
+ * Keeping the arm leaks nothing serve does not already hand over, and this
5511
+ * is the shape every deployment that has not wired the security plugin
5512
+ * gets: search is byte-for-byte unchanged. Checked first, and that order
5513
+ * is load-bearing — see the object-scope note below.
5514
+ * - **authority wired** — the readable set is per object, so the arm is
5515
+ * admissible only for a scope of exactly one KNOWN object. With
5516
+ * `filter.object` present that object is known at predicate time and the
5517
+ * seam is asked about it directly. Absent, the query spans every object
5518
+ * and there is nothing sound to ask, so the arm is dropped.
5519
+ *
5520
+ * Dropping an arm is strictly NARROWING: it never refuses a query and never
5521
+ * widens what comes back, so the fail-closed direction is cheap here and is
5522
+ * taken rather than reaching for a per-object predicate machine to avoid it.
5523
+ * Refusing the query outright would be the louder behaviour change, and is
5524
+ * deliberately not what this does.
5525
+ */
5526
+ async freeTextMayMatchSnapshot(objectInScope, context) {
5527
+ const authority = this.fieldVisibility;
5528
+ if (!authority || typeof authority.getReadableFields !== "function") return true;
5529
+ const object = String(objectInScope ?? "").trim();
5530
+ if (!object) return false;
5531
+ const readable = await resolveReadableSnapshotFields(
5532
+ authority,
5533
+ object,
5534
+ context,
5535
+ this.logger
5536
+ );
5537
+ return readable === void 0;
5538
+ }
5021
5539
  /** Filter type accepted by {@link listRequests} / {@link countRequests}. */
5022
- buildRequestWhere(filter, context) {
5540
+ async buildRequestWhere(filter, context) {
5023
5541
  const f = {};
5024
5542
  if (filter?.object) f.object_name = filter.object;
5025
5543
  if (filter?.recordId) f.record_id = filter.recordId;
@@ -5028,13 +5546,16 @@ var _ApprovalService = class _ApprovalService {
5028
5546
  if (tenantOrg) f.organization_id = tenantOrg;
5029
5547
  const q = filter?.q?.trim();
5030
5548
  if (q) {
5031
- f.$or = [
5549
+ const arms = [
5032
5550
  { process_name: { $contains: q } },
5033
5551
  { object_name: { $contains: q } },
5034
5552
  { record_id: { $contains: q } },
5035
- { submitter_id: { $contains: q } },
5036
- { payload_json: { $contains: q } }
5553
+ { submitter_id: { $contains: q } }
5037
5554
  ];
5555
+ if (await this.freeTextMayMatchSnapshot(filter?.object, context)) {
5556
+ arms.push({ payload_json: { $contains: q } });
5557
+ }
5558
+ f.$or = arms;
5038
5559
  }
5039
5560
  if (Array.isArray(filter?.status)) {
5040
5561
  const statuses = filter.status.filter(Boolean);
@@ -5095,7 +5616,7 @@ var _ApprovalService = class _ApprovalService {
5095
5616
  * is a plain membership test over the resolved ids). So this cannot hide a
5096
5617
  * request from someone who could actually act on it.
5097
5618
  */
5098
- async visibleRequestIds(context, tenantOrg) {
5619
+ async visibleRequestIds(context, tenantOrg, target) {
5099
5620
  if (this.isOverrideActor(context, tenantOrg)) return null;
5100
5621
  const uid2 = context?.userId != null ? String(context.userId) : "";
5101
5622
  if (!uid2) return /* @__PURE__ */ new Set();
@@ -5137,8 +5658,90 @@ var _ApprovalService = class _ApprovalService {
5137
5658
  error: err instanceof Error ? err.message : String(err)
5138
5659
  });
5139
5660
  }
5661
+ await this.addRecordReaderVisibleIds(ids, context, tenantOrg, target);
5140
5662
  return ids;
5141
5663
  }
5664
+ /**
5665
+ * [#8652] Read-only approval visibility derived from READ ACCESS TO THE
5666
+ * TARGET BUSINESS RECORD.
5667
+ *
5668
+ * Maintainer ruling 2026-08-15: a user who can read the target record may
5669
+ * view that record's approval requests and full action history, read-only,
5670
+ * behind a switch that is default OFF, anchored on the EXISTING record-read
5671
+ * permission. The rejected alternative was a host-injected visibility hook —
5672
+ * a security predicate the platform could neither constrain nor audit.
5673
+ *
5674
+ * ## How the anchor is evaluated
5675
+ *
5676
+ * By asking the engine to read the record AS THE CALLER. That is the whole
5677
+ * check: `engine.find(object, { where: { id }, context })` runs the ordinary
5678
+ * ObjectQL middleware — object CRUD read, then RLS — so a denial throws and a
5679
+ * row the caller may not see comes back empty. Both mean "no". No new
5680
+ * permission, role or grant type is invented, and no second copy of the
5681
+ * access rule exists to drift from the first.
5682
+ *
5683
+ * ⚠️ The caller's context is load-bearing. Probing with {@link SYSTEM_CTX} —
5684
+ * the context every other read in this service uses — would read exactly like
5685
+ * a permission check while admitting every authenticated user in the tenant.
5686
+ *
5687
+ * ## Why it needs a NAMED TARGET, and what that deliberately excludes
5688
+ *
5689
+ * The rule is anchored on one record, so it can only be evaluated where a
5690
+ * record is named: a list filtered by `object` + `recordId` (what a record
5691
+ * page's approval tab sends), or a request loaded by id (whose own row names
5692
+ * its target). An UNTARGETED list — the inbox — is left exactly as it was:
5693
+ * answering it under this tier would mean probing every request in the tenant
5694
+ * for read access, which is unbounded, and would turn a work queue into a
5695
+ * browse surface. The confirmed consumer is the record page; the inbox is not
5696
+ * part of the ruling and is not widened here.
5697
+ *
5698
+ * ## What becomes visible (stated plainly, because the switch is an opt-in)
5699
+ *
5700
+ * The request row — including its `payload` snapshot of the record at
5701
+ * submission time — plus the full action history: actor, decision, timestamp,
5702
+ * the action's COMMENT text, and (through the same gate, via
5703
+ * {@link ApprovalService.authorizeFileRead}) any decision attachments. The
5704
+ * comment text is the ruling's "full action history" read literally; it is
5705
+ * flagged on the card as the one granularity edge worth a second look.
5706
+ *
5707
+ * Read-only is not enforced here and must not be: the decision paths
5708
+ * (`decideNode` / `reassign` / `recall` / `comment`) authorize on the pending
5709
+ * approver slate, the submitter, or {@link ApprovalService.isOverrideActor},
5710
+ * none of which this tier touches. Seeing a request confers nothing.
5711
+ */
5712
+ async addRecordReaderVisibleIds(ids, context, tenantOrg, target) {
5713
+ if (this.recordReaderVisibleObjects.size === 0) return;
5714
+ const object = String(target?.object ?? "").trim();
5715
+ const recordId = String(target?.recordId ?? "").trim();
5716
+ if (!object || !recordId) return;
5717
+ if (!this.recordReaderVisibleObjects.has(object)) return;
5718
+ const uid2 = context?.userId != null ? String(context.userId) : "";
5719
+ if (!uid2) return;
5720
+ try {
5721
+ const readable = await this.engine.find(object, {
5722
+ where: { id: recordId },
5723
+ fields: ["id"],
5724
+ limit: 1,
5725
+ context
5726
+ });
5727
+ if (!Array.isArray(readable) || readable.length === 0) return;
5728
+ const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
5729
+ const rows = await this.engine.find("sys_approval_request", {
5730
+ where: { object_name: object, record_id: recordId, ...orgWhere },
5731
+ fields: ["id"],
5732
+ limit: _ApprovalService.APPROVER_INDEX_CAP,
5733
+ context: SYSTEM_CTX2
5734
+ });
5735
+ for (const r of Array.isArray(rows) ? rows : []) {
5736
+ if (r?.id != null) ids.add(String(r.id));
5737
+ }
5738
+ } catch (err) {
5739
+ this.logger?.debug?.("[approvals] record-reader visibility probe declined", {
5740
+ object,
5741
+ error: err instanceof Error ? err.message : String(err)
5742
+ });
5743
+ }
5744
+ }
5142
5745
  /** Intersect an existing `where.id` constraint with the participant set. */
5143
5746
  applyVisibility(where, visible) {
5144
5747
  if (!visible) return true;
@@ -5155,14 +5758,17 @@ var _ApprovalService = class _ApprovalService {
5155
5758
  return true;
5156
5759
  }
5157
5760
  async listRequests(filter, context) {
5158
- const { where, tenantOrg } = this.buildRequestWhere(filter, context);
5761
+ const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
5159
5762
  const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
5160
5763
  const ids = await this.approverRequestIds(approverTargets, tenantOrg);
5161
5764
  if (ids) {
5162
5765
  if (ids.length === 0) return [];
5163
5766
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
5164
5767
  }
5165
- if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return [];
5768
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg, {
5769
+ object: filter?.object,
5770
+ recordId: filter?.recordId
5771
+ }))) return [];
5166
5772
  const findOpts = {
5167
5773
  where,
5168
5774
  orderBy: [{ field: "created_at", order: "desc" }],
@@ -5176,19 +5782,23 @@ var _ApprovalService = class _ApprovalService {
5176
5782
  }
5177
5783
  const rows = await this.engine.find("sys_approval_request", findOpts);
5178
5784
  const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
5785
+ await this.redactPayloads(list, context);
5179
5786
  await this.enrichRows(list);
5180
5787
  this.attachViewers(list, context);
5181
5788
  return list;
5182
5789
  }
5183
5790
  async countRequests(filter, context) {
5184
- const { where, tenantOrg } = this.buildRequestWhere(filter, context);
5791
+ const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
5185
5792
  const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
5186
5793
  const ids = await this.approverRequestIds(approverTargets, tenantOrg);
5187
5794
  if (ids) {
5188
5795
  if (ids.length === 0) return 0;
5189
5796
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
5190
5797
  }
5191
- if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return 0;
5798
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg, {
5799
+ object: filter?.object,
5800
+ recordId: filter?.recordId
5801
+ }))) return 0;
5192
5802
  const countFn = this.engine.count;
5193
5803
  if (typeof countFn === "function") {
5194
5804
  try {
@@ -5234,10 +5844,14 @@ var _ApprovalService = class _ApprovalService {
5234
5844
  });
5235
5845
  if (!Array.isArray(rows) || !rows[0]) return null;
5236
5846
  if (enforceVisibility) {
5237
- const visible = await this.visibleRequestIds(context, tenantOrg ?? null);
5847
+ const visible = await this.visibleRequestIds(context, tenantOrg ?? null, {
5848
+ object: rows[0].object_name,
5849
+ recordId: rows[0].record_id
5850
+ });
5238
5851
  if (visible && !visible.has(String(rows[0].id))) return null;
5239
5852
  }
5240
5853
  const row = rowFromRequest(rows[0]);
5854
+ await this.redactPayloads([row], context);
5241
5855
  await this.enrichRows([row]);
5242
5856
  await this.attachFlowSteps(row);
5243
5857
  await this.attachDecisionProgress(row, rows[0]);
@@ -5741,6 +6355,52 @@ function unbindAllHooks(engine) {
5741
6355
  return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
5742
6356
  }
5743
6357
 
6358
+ // src/payload-redaction-middleware.ts
6359
+ var APPROVAL_REQUEST_OBJECT = "sys_approval_request";
6360
+ function parseSnapshot(raw) {
6361
+ if (typeof raw !== "string" || raw.trim() === "") return { ok: false, value: void 0 };
6362
+ try {
6363
+ return { ok: true, value: JSON.parse(raw) };
6364
+ } catch {
6365
+ return { ok: false, value: void 0 };
6366
+ }
6367
+ }
6368
+ async function redactRowsInPlace(rows, security, context, logger) {
6369
+ const list = Array.isArray(rows) ? rows : rows ? [rows] : [];
6370
+ if (list.length === 0) return;
6371
+ const cache = /* @__PURE__ */ new Map();
6372
+ for (const row of list) {
6373
+ if (!row || typeof row !== "object") continue;
6374
+ const raw = row.payload_json;
6375
+ const parsed = parseSnapshot(raw);
6376
+ if (!parsed.ok) continue;
6377
+ const object = String(row.object_name ?? "").trim();
6378
+ if (!object) continue;
6379
+ if (!cache.has(object)) {
6380
+ cache.set(object, await resolveReadableSnapshotFields(security, object, context, logger));
6381
+ }
6382
+ const readable = cache.get(object);
6383
+ if (readable === void 0) continue;
6384
+ const { payload, redactedKeys } = redactSnapshot(parsed.value, readable);
6385
+ if (redactedKeys.length === 0) continue;
6386
+ row.payload_json = JSON.stringify(payload);
6387
+ }
6388
+ }
6389
+ function bindSnapshotRedactionMiddleware(engine, getSecurity, logger) {
6390
+ engine.registerMiddleware(async (opCtx, next) => {
6391
+ await next();
6392
+ if (opCtx?.operation !== "find" && opCtx?.operation !== "findOne") return;
6393
+ if (opCtx?.context?.isSystem) return;
6394
+ try {
6395
+ await redactRowsInPlace(opCtx.result, getSecurity(), opCtx.context, logger);
6396
+ } catch (err) {
6397
+ logger?.warn?.("[approvals] snapshot redaction middleware failed", {
6398
+ error: err?.message ?? String(err)
6399
+ });
6400
+ }
6401
+ }, { object: APPROVAL_REQUEST_OBJECT });
6402
+ }
6403
+
5744
6404
  // src/approval-node.ts
5745
6405
  import {
5746
6406
  defineActionDescriptor as defineActionDescriptor2,
@@ -5994,6 +6654,9 @@ var ApprovalsServicePlugin = class {
5994
6654
  engine,
5995
6655
  logger: ctx.logger,
5996
6656
  publicBaseUrl: this.options.publicBaseUrl,
6657
+ // [#8652] Read-only record-reader visibility. Default OFF — an absent
6658
+ // declaration reaches the service as an empty set and changes nothing.
6659
+ recordReaderVisibleObjects: this.options.recordReaderVisibleObjects,
5997
6660
  // [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
5998
6661
  // capability. Read LAZILY (not captured at start) because the tenancy
5999
6662
  // service resolves its posture during its own start, which may not have
@@ -6009,11 +6672,26 @@ var ApprovalsServicePlugin = class {
6009
6672
  }
6010
6673
  }
6011
6674
  });
6675
+ const fieldVisibility = () => {
6676
+ try {
6677
+ const sec = ctx.getService("security");
6678
+ return sec && typeof sec.getReadableFields === "function" ? sec : void 0;
6679
+ } catch {
6680
+ return void 0;
6681
+ }
6682
+ };
6683
+ this.service.attachFieldVisibility({
6684
+ getReadableFields: (object, context) => {
6685
+ const sec = fieldVisibility();
6686
+ return sec ? sec.getReadableFields(object, context) : Promise.resolve(void 0);
6687
+ }
6688
+ });
6012
6689
  if (!this.options.disableAutoHooks) {
6013
6690
  try {
6014
6691
  unbindAllHooks(engine);
6015
6692
  bindApprovalLockHook(engine, ctx.logger);
6016
6693
  bindDelegationWriteGuard(engine, ctx.logger);
6694
+ bindSnapshotRedactionMiddleware(engine, fieldVisibility, ctx.logger);
6017
6695
  } catch (err) {
6018
6696
  ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
6019
6697
  }
@@ -6053,6 +6731,7 @@ var ApprovalsServicePlugin = class {
6053
6731
  }
6054
6732
  };
6055
6733
  await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
6734
+ this.jobService = jobs;
6056
6735
  this.escalationJobScheduled = true;
6057
6736
  void sweep().catch((err) => {
6058
6737
  ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
@@ -6139,14 +6818,32 @@ var ApprovalsServicePlugin = class {
6139
6818
  );
6140
6819
  }
6141
6820
  }
6142
- async stop(ctx) {
6821
+ /**
6822
+ * The kernel's teardown hook (`Plugin.destroy?()`, core `types.ts`) — the
6823
+ * ONLY teardown entry point `ObjectKernel.performShutdown()` and
6824
+ * `LiteKernel.destroy()` invoke.
6825
+ *
6826
+ * [#10371] IT USED TO BE `stop()`, WHICH NOTHING CALLED. `Plugin` declares
6827
+ * `init()`, `start?()` and `destroy?()` and no `stop()`, so the kernel walked
6828
+ * past this plugin at shutdown: the SLA escalation job stayed scheduled and
6829
+ * this plugin's ObjectQL hooks stayed bound to an engine the kernel had
6830
+ * finished with. `start()` IS on the interface, so the pair read as symmetric
6831
+ * in review — that asymmetry is what let the same shape survive in six
6832
+ * packages at once.
6833
+ *
6834
+ * This member owns no timer of its own (the escalation clock belongs to
6835
+ * `service-job`), so it never cost a merge-queue eviction the way the
6836
+ * `plugin-reports` / `service-messaging` members did (#9371). The class is
6837
+ * the same one either way: a teardown the kernel does not reach.
6838
+ */
6839
+ async destroy() {
6143
6840
  if (this.escalationJobScheduled) {
6144
6841
  try {
6145
- const jobs = ctx.getService("job");
6146
- await jobs?.cancel?.(ESCALATION_JOB_NAME);
6842
+ await this.jobService?.cancel?.(ESCALATION_JOB_NAME);
6147
6843
  } catch {
6148
6844
  }
6149
6845
  this.escalationJobScheduled = false;
6846
+ this.jobService = void 0;
6150
6847
  }
6151
6848
  if (this.engine) {
6152
6849
  try {
@@ -6155,6 +6852,17 @@ var ApprovalsServicePlugin = class {
6155
6852
  }
6156
6853
  }
6157
6854
  }
6855
+ /**
6856
+ * Retained alias for {@link destroy}. Kept because it is public API of an
6857
+ * exported class, and removing it would break an embedder who learned to call
6858
+ * it directly precisely BECAUSE the kernel never did. The parameter is now
6859
+ * optional and ignored: `destroy()` takes no context, so teardown uses the
6860
+ * job service captured when the escalation clock was wired. Prefer kernel
6861
+ * shutdown; direct callers keep working unchanged.
6862
+ */
6863
+ async stop(_ctx) {
6864
+ await this.destroy();
6865
+ }
6158
6866
  };
6159
6867
  export {
6160
6868
  APPROVAL_REVISE_CORRELATION_PREFIX,