From aacebceb023ad3314270ff02ed3c22424b177969 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 23:16:23 +0000 Subject: [PATCH 1/5] feat(plugin-approvals): excludeSubmitter keeps the submitter off every approval slate (WIP) Claude-Session: https://claude.ai/code/session_01LXX9EBHog6ZMo6apV2jvXX Co-authored-by: Claude --- .../plugin-approvals/src/admin-rescue-note.ts | 36 ++- .../plugin-approvals/src/approval-service.ts | 215 ++++++++++++++++-- .../src/approver-address-readers.test.ts | 6 +- .../src/submitter-exclusion-note.ts | 102 +++++++++ .../src/translations/messages.ts | 32 ++- 5 files changed, 355 insertions(+), 36 deletions(-) create mode 100644 packages/plugins/plugin-approvals/src/submitter-exclusion-note.ts diff --git a/packages/plugins/plugin-approvals/src/admin-rescue-note.ts b/packages/plugins/plugin-approvals/src/admin-rescue-note.ts index 1bf2cd07cec..510a2afca78 100644 --- a/packages/plugins/plugin-approvals/src/admin-rescue-note.ts +++ b/packages/plugins/plugin-approvals/src/admin-rescue-note.ts @@ -59,6 +59,12 @@ export interface AdminRescueNote { readonly abandoned: readonly string[]; /** Set when the node declared `onEmptyApprovers: 'fallback'` and it did not help. */ readonly fallback?: AdminRescueFallback; + /** + * [#25573] Set when the slate named a person, the request's submitter, and + * the node's `excludeSubmitter: true` removed them: the slate named no one + * else, which "no approver resolved to a person" would misstate. + */ + readonly submitter_excluded?: true; } /** One catalog sentence and its params. */ @@ -73,8 +79,15 @@ export type ApprovalSentenceRender = ( params: Readonly>, ) => string | undefined; -/** The reason's clauses: one per abandoned `type:value` slot, then the fallback's. */ -function reasonClauses(abandoned: readonly string[], fallback: AdminRescueFallback | undefined): Clause[] { +/** + * The reason's clauses: one per abandoned `type:value` slot, then the + * submitter's exclusion, then the fallback's. + */ +function reasonClauses( + abandoned: readonly string[], + fallback: AdminRescueFallback | undefined, + submitterExcluded?: boolean, +): Clause[] { const clauses: Clause[] = abandoned .filter((slot) => !!slot && slot.includes(':')) .map((slot): Clause => { @@ -85,6 +98,7 @@ function reasonClauses(abandoned: readonly string[], fallback: AdminRescueFallba ? { id: 'approvalAdminRescuePositionUnheld', params: { value } } : { id: 'approvalAdminRescueSlotUnresolved', params: { type, value } }; }); + if (submitterExcluded) clauses.push({ id: 'approvalAdminRescueSubmitterExcluded', params: {} }); if (!clauses.length) clauses.push({ id: 'approvalAdminRescueNoApprover', params: {} }); if (fallback === 'resolved_to_no_one') clauses.push({ id: 'approvalAdminRescueFallbackUnresolved', params: {} }); if (fallback === 'not_declared') clauses.push({ id: 'approvalAdminRescueFallbackUndeclared', params: {} }); @@ -97,8 +111,13 @@ const english = (clause: Clause): string => interpolate(enMessages[clause.id], c * Why the slate named no one, in English: the audit snapshot's * `admin_rescue:reason` and the log lines. */ -export function adminRescueReason(abandoned: readonly string[], fallback: AdminRescueFallback | undefined): string { - return reasonClauses(abandoned, fallback).map(english).join(enMessages.approvalAdminRescueReasonSeparator); +export function adminRescueReason( + abandoned: readonly string[], + fallback: AdminRescueFallback | undefined, + submitterExcluded?: boolean, +): string { + return reasonClauses(abandoned, fallback, submitterExcluded) + .map(english).join(enMessages.approvalAdminRescueReasonSeparator); } /** @@ -115,7 +134,7 @@ export function adminRescueNoteText( note: Omit, render?: ApprovalSentenceRender, ): string | undefined { - const clauses = reasonClauses(note.abandoned, note.fallback); + const clauses = reasonClauses(note.abandoned, note.fallback, note.submitter_excluded); if (!render) { const reason = clauses.map(english).join(enMessages.approvalAdminRescueReasonSeparator); return interpolate(enMessages.approvalAdminRescueNote, { reason }); @@ -136,9 +155,12 @@ export function adminRescueNoteOf(snapshot: unknown): AdminRescueNote | undefine if (!snapshot || typeof snapshot !== 'object') return undefined; const note = (snapshot as Record)[ADMIN_RESCUE_NOTE_SNAPSHOT_KEY]; if (!note || typeof note !== 'object') return undefined; - const { action_id, abandoned, fallback } = note as Record; + const { action_id, abandoned, fallback, submitter_excluded } = note as Record; if (typeof action_id !== 'string' || !action_id) return undefined; if (!Array.isArray(abandoned) || !abandoned.every((slot) => typeof slot === 'string')) return undefined; if (fallback !== undefined && fallback !== 'not_declared' && fallback !== 'resolved_to_no_one') return undefined; - return { action_id, abandoned, ...(fallback ? { fallback } : {}) }; + if (submitter_excluded !== undefined && submitter_excluded !== true) return undefined; + return { + action_id, abandoned, ...(fallback ? { fallback } : {}), ...(submitter_excluded ? { submitter_excluded } : {}), + }; } diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 500547c293a..b8865b66f24 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -101,6 +101,12 @@ import { adminRescueReason, type AdminRescueNote, } from './admin-rescue-note.js'; +import { + SUBMITTER_EXCLUSION_NOTE_SNAPSHOT_KEY, + submitterExclusionNoteOf, + submitterExclusionNoteText, + type SubmitterExclusionNote, +} from './submitter-exclusion-note.js'; import { localizeRequestLabels, readerLabelsFor, @@ -2091,6 +2097,23 @@ export class ApprovalService implements IApprovalService { } } + /** + * [#25573] The slate slots a step that sets `excludeSubmitter: true` keeps + * its submitter out of: the addresses the submitter acts under without + * naming one (`actingAddresses`: the user id and the account's email). None + * when the step does not set it, or the request has no submitter. Read by + * every writer of a slate — the open, the reassign door and the SLA + * escalation — so the three keep the same person off it. + */ + private async excludedSubmitterSlots( + config: { excludeSubmitter?: unknown } | null | undefined, + submitterId: unknown, + ): Promise { + if (config?.excludeSubmitter !== true || submitterId == null || String(submitterId) === '') return []; + const userId = String(submitterId); + return actingAddresses({ userId, email: await this.callerAccountEmail(userId) }); + } + /** * The caller's server-resolved acting identity (`approver-address.ts`'s * `ActingCaller`): the context's user id, the email that user's own account @@ -3621,15 +3644,46 @@ export class ApprovalService implements IApprovalService { const requestOrg = subjectOrg ?? ctxOrg; const resolvedFrom: Record = {}; const exprCtx = { trigger: input.record ?? null, vars: input.variables ?? null }; + const submitterId = input.submitterId !== undefined ? input.submitterId : (context.userId ?? null); + // [#25573] `excludeSubmitter: true` (ruling 6377888641, letter B): the + // submitter is taken off every slate this request can open on. It runs on + // the RESOLVED slate, so the one seam covers every approver type (`user`, + // `field`, `manager`, `position`, `team`, `department`, + // `org_membership_level`, `expression`) and an out-of-office hop that lands + // on the submitter. The slots removed are the addresses the submitter acts + // under without naming one ({@link excludedSubmitterSlots}), never a + // `position:

` literal, which other holders take. `approvers`, the `fallback` slate and the administrators an + // `admin_rescue` opens on are all screened, because the ruled outcome is + // "never approved by the submitter": a submitter who is also on the + // fallback list or an administrator would otherwise get their own request + // back one route later. A slate this empties takes the node's + // `onEmptyApprovers` route like any slate that names no one; that route is + // never `auto_approve`, which the spec refuses beside `excludeSubmitter: + // true`. Off (the default), no slate is touched and nothing is read. + const submitterSlots = await this.excludedSubmitterSlots(input.config, submitterId); + /** The slates the exclusion took the submitter off, for the route note. */ + const excludedFrom = new Set<'approvers' | 'fallback' | 'administrators'>(); + const withoutSubmitter = ( + slate: string[], from: 'approvers' | 'fallback' | 'administrators', slateGroups: Record, + ): string[] => { + const kept = slate.filter((slot) => !submitterSlots.includes(slot)); + if (kept.length !== slate.length) { + excludedFrom.add(from); + for (const slot of submitterSlots) delete slateGroups[slot]; + } + return kept; + }; + /** Which slate the request opens on — what the route note names. */ + let slateSource: 'approvers' | 'fallback' | 'administrators' | 'no_one' = 'approvers'; // `let`, not `const`: the `fallback` empty-slate policy below REPLACES this // slate with the node's declared fallback approvers. - let approvers = await this.expandApprovers( + let approvers = withoutSubmitter(await this.expandApprovers( { approvers: input.config.approvers }, liveRecord, requestOrg, { now: nowDate.getTime(), substitutions, groups, exprCtx, resolvedFrom, }, - ); + ), 'approvers', groups); // Empty-slate policy (#3447 P2). "Empty" = no CONCRETE person — an // unstaffed position / empty expression result leaves only `type:value` @@ -3649,7 +3703,9 @@ export class ApprovalService implements IApprovalService { if (emptyPolicy === 'fail') { throw new Error( `NO_APPROVERS: approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no ` - + `concrete approver and its onEmptyApprovers policy is 'fail'. Check that the approver target(s) ` + + `concrete approver${excludedFrom.has('approvers') + ? " other than the request's submitter, whom the node's excludeSubmitter removes," + : ''} and its onEmptyApprovers policy is 'fail'. Check that the approver target(s) ` + `are staffed / the routing field or expression yields user ids at node entry.`, ); } @@ -3680,13 +3736,13 @@ export class ApprovalService implements IApprovalService { // the policy was chosen to avoid. if (Array.isArray(declared) && declared.length) { const fallbackResolvedFrom: Record = {}; - const fallbackApprovers = await this.expandApprovers( + const fallbackApprovers = withoutSubmitter(await this.expandApprovers( { approvers: declared }, liveRecord, requestOrg, { now: nowDate.getTime(), substitutions, groups, exprCtx, resolvedFrom: fallbackResolvedFrom, }, - ); + ), 'fallback', groups); // Keep the primary slate's resolution inputs (they explain WHY the // fallback fired) and namespace the fallback's own, so neither // clobbers the other in the `__resolvedFrom` audit snapshot. @@ -3707,6 +3763,7 @@ export class ApprovalService implements IApprovalService { // describes the slate the approvers actually see. for (const slot of approvers) delete groups[slot]; approvers = fallbackApprovers; + slateSource = 'fallback'; } else { fallbackDegraded = 'resolved_to_no_one'; this.logger?.warn?.( @@ -3748,20 +3805,33 @@ export class ApprovalService implements IApprovalService { // Re-tested rather than assumed: a `fallback` that landed people above // has already turned this into a normal request. if (!slateNamesAPerson(approvers)) { - const reason = adminRescueReason(approvers, fallbackDegraded); + const reason = adminRescueReason(approvers, fallbackDegraded, excludedFrom.has('approvers')); const found = await this.organizationAdministrators(requestOrg); const administrators = 'ids' in found ? found.ids : []; - if (administrators.length) { + // [#25573] Expanded into collectors of its own, and adopted only when + // the slate still names someone once the submitter is off it: an + // organization whose only administrator submitted the request has no + // one here, and its out-of-office hops must not be told about a slate + // the request does not open on. + const administratorGroups: Record = {}; + const administratorSubstitutions: OooSubstitution[] = []; + const administratorSlate = administrators.length + ? withoutSubmitter(await this.expandApprovers( + { + approvers: administrators.map((value) => ({ type: 'user', value, group: ADMIN_RESCUE_GROUP })), + }, + liveRecord, requestOrg, { + now: nowDate.getTime(), substitutions: administratorSubstitutions, groups: administratorGroups, exprCtx, + }, + ), 'administrators', administratorGroups) + : []; + if (slateNamesAPerson(administratorSlate)) { // Every per_group tag collected so far describes a slot that is no // longer on this request (the primary slate's literals, and a failed // fallback's): the tally reads the administrators' one group only. for (const slot of Object.keys(groups)) delete groups[slot]; - const administratorSlate = await this.expandApprovers( - { - approvers: administrators.map((value) => ({ type: 'user', value, group: ADMIN_RESCUE_GROUP })), - }, - liveRecord, requestOrg, { now: nowDate.getTime(), substitutions, groups, exprCtx }, - ); + Object.assign(groups, administratorGroups); + substitutions.push(...administratorSubstitutions); resolvedFrom['admin_rescue:abandoned'] = [...approvers]; resolvedFrom['admin_rescue:reason'] = reason; this.logger?.warn?.( @@ -3778,9 +3848,12 @@ export class ApprovalService implements IApprovalService { action_id: uid('aact'), abandoned: [...approvers], ...(fallbackDegraded ? { fallback: fallbackDegraded } : {}), + ...(excludedFrom.has('approvers') ? { submitter_excluded: true as const } : {}), }; approvers = administratorSlate; + slateSource = 'administrators'; } else { + slateSource = 'no_one'; // Break-glass stays break-glass (#3424): an organization whose // administrators' slate resolves to nobody opens the request on the // slate that names no one — the only option that neither waves the @@ -3788,7 +3861,10 @@ export class ApprovalService implements IApprovalService { // override can decide it. Said loudly, with the fix. const why = 'failure' in found ? `the administrators of organization '${requestOrg}' could not be read (${found.failure})` - : requestOrg + : administrators.length + ? `every administrator of organization '${requestOrg}' resolves to the request's submitter, whom ` + + `the node's excludeSubmitter removes` + : requestOrg ? `organization '${requestOrg}' has no administrator` : 'the request has no organization, so it has no organization administrators'; this.logger?.warn?.( @@ -3830,6 +3906,12 @@ export class ApprovalService implements IApprovalService { configSnapshot.__resolvedFrom = resolvedFrom; } if (adminRescueNote) configSnapshot[ADMIN_RESCUE_NOTE_SNAPSHOT_KEY] = adminRescueNote; + // [#25573] The record shows when the exclusion changed the route: the + // route the request took once the submitter was off its slate. + const exclusionNote: SubmitterExclusionNote | undefined = excludedFrom.size + ? { action_id: uid('aact'), route: slateSource === 'approvers' ? 'remaining_approvers' : slateSource } + : undefined; + if (exclusionNote) configSnapshot[SUBMITTER_EXCLUSION_NOTE_SNAPSHOT_KEY] = exclusionNote; // ADR-0044 round numbering: rounds of a revise loop share the run — count // this (run, node)'s prior requests; the new one is round N+1. Stamped on // the snapshot (precedent: __flowLabel), so no schema migration. @@ -3840,7 +3922,6 @@ export class ApprovalService implements IApprovalService { const n = Array.isArray(prior) ? prior.length : 0; if (n > 0) configSnapshot.__round = n + 1; } catch { /* round display is best-effort */ } - const submitterId = input.submitterId !== undefined ? input.submitterId : (context.userId ?? null); const row: any = { id, process_name: processName, @@ -3895,6 +3976,17 @@ export class ApprovalService implements IApprovalService { created_at: now, }), { context: SYSTEM_CTX }); } + // [#25573] Its sibling note, the same shape: no person took the submitter + // off the slate, the node's `excludeSubmitter` did. + if (exclusionNote) { + await this.engine.insert('sys_approval_action', await this.actionRow(row, { + id: exclusionNote.action_id, request_id: id, organization_id: requestOrg, + step_name: input.nodeId, step_index: 0, action: 'comment', + actor_id: null, + comment: submitterExclusionNoteText(exclusionNote), + created_at: now, + }), { context: SYSTEM_CTX }); + } // OOO substitution audit + notification (#1322 M4). Each hop that rerouted // an approver away from an out-of-office user is recorded on the request's @@ -4109,6 +4201,25 @@ export class ApprovalService implements IApprovalService { const runId: string | null = raw.flow_run_id ?? null; const now = this.clock.now().toISOString(); + // [#25573] `excludeSubmitter: true` holds at the decision as well as at + // the open: the submitter never approves their own request, whatever put + // a slot in their hands after it opened (a `position:

` literal they + // were staffed into later) and through the privileged override too. The + // reassign door and the SLA escalation keep the submitter off the slate; + // this is the one check every approval passes through. Refused before any + // write. A rejection approves nothing, so it stays theirs to make. + const decider = recordedActor(context); + if ( + input.decision === 'approve' && config.excludeSubmitter === true + && raw.submitter_id && decider && decider === String(raw.submitter_id) + ) { + throw new Error( + `FORBIDDEN: '${decider}' submitted this request, and its approval step sets excludeSubmitter, so they ` + + `cannot approve it. Another approver on the step decides it; if the submitter holds the only slot, ` + + `reassign it to someone else.`, + ); + } + // #3447 P2: decision outputs — validated BEFORE any write (audit included) // so an out-of-contract payload rejects atomically. The trust model is a // `screen` node's: the AUTHOR declares the keys (`config.decisionOutputs`), @@ -5507,6 +5618,7 @@ export class ApprovalService implements IApprovalService { // AFTER the authorization branches above, so a caller who may not move // this slot learns nothing about the directory from the answer. await this.assertReassignTargetResolves(to, raw.organization_id ?? null); + await this.assertReassignTargetIsNotSubmitter(raw, to); const now = this.clock.now().toISOString(); // Audit first, then mutate — mirrors decideNode(), so a failed audit // write can never leave a moved slot without a trail. @@ -5602,6 +5714,33 @@ export class ApprovalService implements IApprovalService { ); } + /** + * [#25573] Refuse a reassign that hands a step setting `excludeSubmitter: + * true` back to its submitter: their own address, or a position only they + * hold in the request's organization. Either leaves a slot only the + * submitter can take, and `decideNode` refuses their approval, so the + * request would wait on a slot no one can approve. Asked after the + * authorization branches, like {@link assertReassignTargetResolves}. + */ + private async assertReassignTargetIsNotSubmitter(raw: any, to: string): Promise { + const submitterSlots = await this.excludedSubmitterSlots( + parseJson<{ excludeSubmitter?: unknown } | null>(raw.node_config_json, null), raw.submitter_id, + ); + if (!submitterSlots.length) return; + const position = addressedPosition(to); + const handsBack = position !== undefined + ? (await this.expandPositionUsers(position, raw.organization_id ?? null)) + .every((holder) => submitterSlots.includes(holder)) + : submitterSlots.includes(to); + if (!handsBack) return; + throw new Error( + `VALIDATION_FAILED: cannot reassign to '${to}' — ${position !== undefined + ? `the only holder of position '${position}' in this request's organization is` + : 'it names'} the request's submitter, and this approval step sets excludeSubmitter, so the submitter ` + + `cannot approve it. The request and its approvers are unchanged. Reassign to someone else.`, + ); + } + /** * Why `to` resolves to no one in `organizationId` — the sentence a refusal * carries — or `undefined` when someone can take a slot under it. The ONE @@ -7056,6 +7195,20 @@ export class ApprovalService implements IApprovalService { ? await this.reassignTargetRefusal(escalateTo!, raw.organization_id ?? null) : undefined; if (unresolved !== undefined) escalatees = []; + // [#25573] A step that sets `excludeSubmitter: true` is never escalated + // onto its submitter: they are left off the new slate (a position they + // hold with others hands the slot to the others), and a target that is + // the submitter alone hands it to no one, so the slate stays and is told + // the SLA lapsed, as for a target that resolves to no one. + let submitterLeftOff = false; + if (action === 'reassign' && escalatees.length) { + const submitterSlots = await this.excludedSubmitterSlots( + parseJson<{ excludeSubmitter?: unknown } | null>(raw.node_config_json, null), raw.submitter_id, + ); + const kept = escalatees.filter((slot) => !submitterSlots.includes(slot)); + submitterLeftOff = kept.length !== escalatees.length; + escalatees = kept; + } // The action this sweep TAKES: a reassign with no one to hand to notifies. const taken = action === 'reassign' && !escalatees.length ? 'notify' : action; @@ -7070,7 +7223,13 @@ export class ApprovalService implements IApprovalService { step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'escalate', actor_id: null, comment: `${action}${escalateTo ? ` → ${escalateTo}` : ''}` - + (unresolved !== undefined ? ` — not applied: ${unresolved} The pending approvers were kept and notified.` : ''), + + (unresolved !== undefined ? ` — not applied: ${unresolved} The pending approvers were kept and notified.` : '') + + (submitterLeftOff + ? escalatees.length + ? " — the request's submitter was left off the new slate: the step sets excludeSubmitter." + : " — not applied: the target is the request's submitter, whom the step's excludeSubmitter keeps off " + + 'the slate. The pending approvers were kept and notified.' + : ''), created_at: now, }), { context: SYSTEM_CTX }); @@ -8698,12 +8857,14 @@ export class ApprovalService implements IApprovalService { const toName = a.reassign_to ? names.get(String(a.reassign_to)) : undefined; if (toName) a.reassign_to_name = toName; } - await this.localizeAdminRescueNote(requestId, actions, context); + await this.localizeRoutingNotes(requestId, actions, context); return actions; } /** - * [#25576] The administrators' routing note, in the reader's language. + * [#25576] The administrators' routing note, in the reader's language — + * and [#25573] its sibling, the submitter exclusion note + * (`submitter-exclusion-note.ts`), read the same way. * * The note's code and parameters ride the request's node-config snapshot, * naming the timeline row they explain (`admin-rescue-note.ts`); that row's @@ -8715,7 +8876,7 @@ export class ApprovalService implements IApprovalService { * administrators, or one opened before the note was stored as data), or * when the pack holds no note sentence. */ - private async localizeAdminRescueNote( + private async localizeRoutingNotes( requestId: string, actions: ApprovalActionRow[], context: ExecutionContext, @@ -8726,12 +8887,16 @@ export class ApprovalService implements IApprovalService { if (!labels) return; const tenantOrg = (context as any)?.organizationId ?? context?.tenantId; const raw = await findRequestRow(this.engine, requestId, tenantOrg ?? null); - const note = adminRescueNoteOf(parseJson(raw?.node_config_json, undefined)); - if (!note) return; - const row = actions.find((a) => a.id === note.action_id && a.action === 'comment' && !a.actor_id); - if (!row) return; - const text = adminRescueNoteText(note, labels.sentence); - if (text) row.comment = text; + const snapshot = parseJson(raw?.node_config_json, undefined); + const overlay = (actionId: string, render: () => string | undefined): void => { + const row = actions.find((a) => a.id === actionId && a.action === 'comment' && !a.actor_id); + const text = row ? render() : undefined; + if (row && text) row.comment = text; + }; + const rescue = adminRescueNoteOf(snapshot); + if (rescue) overlay(rescue.action_id, () => adminRescueNoteText(rescue, labels.sentence)); + const exclusion = submitterExclusionNoteOf(snapshot); + if (exclusion) overlay(exclusion.action_id, () => submitterExclusionNoteText(exclusion, labels.sentence)); } /** diff --git a/packages/plugins/plugin-approvals/src/approver-address-readers.test.ts b/packages/plugins/plugin-approvals/src/approver-address-readers.test.ts index 21275916344..1d1b3d69a73 100644 --- a/packages/plugins/plugin-approvals/src/approver-address-readers.test.ts +++ b/packages/plugins/plugin-approvals/src/approver-address-readers.test.ts @@ -186,11 +186,11 @@ const PERSON_SITES: Readonly> = { + remaining_approvers: 'approvalSubmitterExcludedRemaining', + fallback: 'approvalSubmitterExcludedFallback', + administrators: 'approvalSubmitterExcludedAdministrators', + no_one: 'approvalSubmitterExcludedNoOne', +}; + +/** + * The note's text. With no `render`, the English the row stores; with one, + * the reader's language, or `undefined` when the pack does not hold the + * sentence (the stored comment then stands). + */ +export function submitterExclusionNoteText(note: Pick): string; +export function submitterExclusionNoteText( + note: Pick, + render: ApprovalSentenceRender, +): string | undefined; +export function submitterExclusionNoteText( + note: Pick, + render?: ApprovalSentenceRender, +): string | undefined { + const id = SENTENCE[note.route]; + return render ? render(approvalMessageKey(id), {}) : enMessages[id]; +} + +/** + * The note a parsed node-config snapshot carries, or `undefined` when it + * carries none or one this module did not write (the stored comment stands). + */ +export function submitterExclusionNoteOf(snapshot: unknown): SubmitterExclusionNote | undefined { + if (!snapshot || typeof snapshot !== 'object') return undefined; + const note = (snapshot as Record)[SUBMITTER_EXCLUSION_NOTE_SNAPSHOT_KEY]; + if (!note || typeof note !== 'object') return undefined; + const { action_id, route } = note as Record; + if (typeof action_id !== 'string' || !action_id) return undefined; + if (!ROUTES.includes(route as SubmitterExclusionRoute)) return undefined; + return { action_id, route: route as SubmitterExclusionRoute }; +} diff --git a/packages/plugins/plugin-approvals/src/translations/messages.ts b/packages/plugins/plugin-approvals/src/translations/messages.ts index 3b31608a2a4..f1f550ee8a9 100644 --- a/packages/plugins/plugin-approvals/src/translations/messages.ts +++ b/packages/plugins/plugin-approvals/src/translations/messages.ts @@ -43,7 +43,17 @@ * clause per approver entry that resolved to no one (`{{type}}` is the * approver type's machine token, `{{value}}` its configured value), joined by * `approvalAdminRescueReasonSeparator`. The `en` note, rendered, is the - * `comment` the row stores. + * `comment` the row stores. `approvalAdminRescueSubmitterExcluded` is the + * clause for a slate that named only the submitter of a node that sets + * `excludeSubmitter`. + * + * ## The submitter exclusion note + * + * [#25573] `approvalSubmitterExcluded*` are the system note a request writes + * when an approval node's `excludeSubmitter: true` took the submitter off a + * slate: one whole sentence per route the request then took + * (`submitter-exclusion-note.ts`), with no holes. Rendered the same way as the + * administrators' note. */ import type { TranslationData } from '@objectstack/spec/system'; @@ -87,6 +97,11 @@ export const enMessages = { approvalAdminRescueFallbackUnresolved: 'the declared fallbackApprovers resolved to no one either', approvalAdminRescueFallbackUndeclared: "onEmptyApprovers is 'fallback' but no fallbackApprovers are declared", approvalAdminRescueReasonSeparator: '; ', + approvalAdminRescueSubmitterExcluded: "the only approver it resolved to was the request's submitter, whom excludeSubmitter removes", + approvalSubmitterExcludedRemaining: "The request's submitter was removed from its approvers because this step sets excludeSubmitter; the remaining approvers decide it.", + approvalSubmitterExcludedFallback: "The request's submitter was removed from its approvers because this step sets excludeSubmitter; the declared fallbackApprovers decide it.", + approvalSubmitterExcludedAdministrators: "The request's submitter was removed from its approvers because this step sets excludeSubmitter; this organization's administrators decide it.", + approvalSubmitterExcludedNoOne: "The request's submitter was removed from its approvers because this step sets excludeSubmitter; no one else could take it, so only a privileged override can decide it.", } as const satisfies Messages; /** One approval notification sentence's id. */ @@ -130,6 +145,11 @@ export const zhCNMessages: ApprovalMessages = { approvalAdminRescueFallbackUnresolved: '声明的 fallbackApprovers 也未解析到任何人', approvalAdminRescueFallbackUndeclared: "onEmptyApprovers 为 'fallback',但未声明 fallbackApprovers", approvalAdminRescueReasonSeparator: ';', + approvalAdminRescueSubmitterExcluded: '唯一解析到的审批人是本请求的提交人,而 excludeSubmitter 会将其排除', + approvalSubmitterExcludedRemaining: '本步骤设置了 excludeSubmitter,提交人已从审批人中移除;由其余审批人决定。', + approvalSubmitterExcludedFallback: '本步骤设置了 excludeSubmitter,提交人已从审批人中移除;由声明的 fallbackApprovers 决定。', + approvalSubmitterExcludedAdministrators: '本步骤设置了 excludeSubmitter,提交人已从审批人中移除;由本组织管理员决定。', + approvalSubmitterExcludedNoOne: '本步骤设置了 excludeSubmitter,提交人已从审批人中移除;没有其他人可以处理,只有特权管理员的强制处理才能决定此请求。', }; export const jaJPMessages: ApprovalMessages = { @@ -168,6 +188,11 @@ export const jaJPMessages: ApprovalMessages = { approvalAdminRescueFallbackUnresolved: '宣言された fallbackApprovers も誰にも解決されませんでした', approvalAdminRescueFallbackUndeclared: "onEmptyApprovers は 'fallback' ですが、fallbackApprovers が宣言されていません", approvalAdminRescueReasonSeparator: ';', + approvalAdminRescueSubmitterExcluded: '解決された唯一の承認者はこの申請の申請者で、excludeSubmitter により除外されます', + approvalSubmitterExcludedRemaining: 'このステップは excludeSubmitter を設定しているため、申請者は承認者から除外されました。他の承認者が判断します。', + approvalSubmitterExcludedFallback: 'このステップは excludeSubmitter を設定しているため、申請者は承認者から除外されました。宣言された fallbackApprovers が判断します。', + approvalSubmitterExcludedAdministrators: 'このステップは excludeSubmitter を設定しているため、申請者は承認者から除外されました。この組織の管理者が判断します。', + approvalSubmitterExcludedNoOne: 'このステップは excludeSubmitter を設定しているため、申請者は承認者から除外されました。他に引き受けられる人がいないため、特権管理者のオーバーライドでのみ判断できます。', }; export const esESMessages: ApprovalMessages = { @@ -206,6 +231,11 @@ export const esESMessages: ApprovalMessages = { approvalAdminRescueFallbackUnresolved: 'los fallbackApprovers declarados tampoco se resolvieron a nadie', approvalAdminRescueFallbackUndeclared: "onEmptyApprovers es 'fallback' pero no se han declarado fallbackApprovers", approvalAdminRescueReasonSeparator: '; ', + approvalAdminRescueSubmitterExcluded: 'el único aprobador resuelto era el solicitante, a quien excludeSubmitter excluye', + approvalSubmitterExcludedRemaining: 'El solicitante se quitó de los aprobadores de este paso porque el paso define excludeSubmitter; la deciden los demás aprobadores.', + approvalSubmitterExcludedFallback: 'El solicitante se quitó de los aprobadores de este paso porque el paso define excludeSubmitter; la deciden los fallbackApprovers declarados.', + approvalSubmitterExcludedAdministrators: 'El solicitante se quitó de los aprobadores de este paso porque el paso define excludeSubmitter; la deciden los administradores de esta organización.', + approvalSubmitterExcludedNoOne: 'El solicitante se quitó de los aprobadores de este paso porque el paso define excludeSubmitter; nadie más puede tomarla, así que solo una anulación privilegiada puede decidirla.', }; /** The four shipped locales, keyed as the bundle beside this file keys them. */ From addd6e1a46491e18fbcc0e602cd3ad30b85e77d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 23:18:39 +0000 Subject: [PATCH 2/5] test(plugin-approvals): pin excludeSubmitter at the open, the decision, the reassign door and the SLA escalation Claude-Session: https://claude.ai/code/session_01LXX9EBHog6ZMo6apV2jvXX Co-authored-by: Claude --- .../src/exclude-submitter.integration.test.ts | 365 ++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 packages/plugins/plugin-approvals/src/exclude-submitter.integration.test.ts diff --git a/packages/plugins/plugin-approvals/src/exclude-submitter.integration.test.ts b/packages/plugins/plugin-approvals/src/exclude-submitter.integration.test.ts new file mode 100644 index 00000000000..6bf72b02ce9 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/exclude-submitter.integration.test.ts @@ -0,0 +1,365 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25573] An approval node's `excludeSubmitter: true` keeps the request's + * submitter from approving it (ruling 6377888641, letter B). + * + * ## The defect + * + * A two-level admission approval routed step 1 to a `field` approver (the + * category manager on the record) and step 2 to a `position`. When the + * category manager submitted the admission themselves, step 1 resolved to the + * submitter and they approved their own request. The spec carries the step + * option `excludeSubmitter` (#25609) and refuses it beside + * `onEmptyApprovers: 'auto_approve'` (#25635, letter A); this is the runtime + * that honours it. + * + * ## What this pins + * + * Driven through the real `ApprovalService` on a real `ObjectQL` over + * `SqlDriver` (better-sqlite3, in-memory, real DDL), with the real + * administrator resolution reading a bound security catalog: + * + * - the submitter is off the slate on a `field` step and on a `position` step, + * and the emptied slate takes the node's `onEmptyApprovers` route (the + * administrators by default, the declared `fallbackApprovers`, or the `fail` + * refusal), never the submitter; + * - a slate minus the submitter keeps everyone else, per_group tags included; + * - the email slot and an out-of-office hop that lands on the submitter are + * the submitter too; + * - the fallback slate and the administrators' slate are screened the same + * way, and an organization whose only administrator is the submitter gets + * the slate that names no one; + * - the request records the route on its timeline, as data the approvals door + * renders in the reader's language; + * - after the open: the decision refuses the submitter's approval (the + * privileged override included) and keeps their rejection; the reassign door + * refuses to hand the slot back to them; an SLA escalation leaves them off; + * - CONTROLS: the option off leaves every slate, timeline and decision as + * before; the option on with a slate that never named the submitter writes + * no note. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { + bindSecurityCatalogReader, createMemoryI18n, type SecurityCatalogEntry, type SecurityCatalogReader, +} from '@objectstack/core'; +import { ApprovalService } from './approval-service.js'; +import { ApprovalsServicePlugin } from './approvals-plugin.js'; +import { ApprovalsTranslations } from './translations/index.js'; +import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; + +const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; + +const ORG = 'org_a'; +/** An organization whose only administrator is the submitter. */ +const SOLO_ORG = 'org_solo'; + +const as = (userId: string, org: string, permissions: string[] = []) => + ({ userId, tenantId: org, positions: [], permissions }) as any; +const SUB = as('u_sub', ORG); +const OWNER = as('u_owner', ORG, ['organization_admin']); + +const USERS = ['u_sub', 'u_owner', 'u_admin', 'u_alice', 'u_bob', 'u_fallback', 'u_dir', 'u_away'] + .map((id) => ({ id, name: id, email: `${id}@example.com` })); + +const MEMBERS: Array<[string, string, string]> = [ + ['u_sub', ORG, 'member'], + ['u_owner', ORG, 'owner'], + ['u_admin', ORG, 'admin'], + ['u_alice', ORG, 'member'], + ['u_bob', ORG, 'member'], + ['u_fallback', ORG, 'member'], + ['u_dir', ORG, 'member'], + ['u_away', ORG, 'member'], + ['u_sub', SOLO_ORG, 'owner'], +]; + +const GRANTS: Array<[string, string, string]> = [ + ['u_owner', 'organization_admin', ORG], + ['u_admin', 'organization_admin_no_bypass', ORG], + ['u_sub', 'organization_admin', SOLO_ORG], +]; + +/** `sys_user_position`: who holds which position, per organization. */ +const POSITIONS: Array<[string, string, string]> = [ + ['u_sub', 'category_manager', ORG], + ['u_sub', 'buyer', ORG], + ['u_alice', 'buyer', ORG], + ['u_dir', 'procurement_director', ORG], + ['u_sub', 'treasury', ORG], +]; + +const text = (name: string) => ({ name, label: name, type: 'text' as const }); +const objectDef = (name: string, fields: string[]) => ({ + name, + label: name, + fields: Object.fromEntries([ + ['id', { ...text('id'), primaryKey: true }], + ...fields.map((f) => [f, text(f)]), + ]), +}); + +function catalogServing(names: string[]): SecurityCatalogReader { + const entry = (name: string): SecurityCatalogEntry => ({ + type: 'permission', name, definition: { name }, source: 'registry', + }); + return { + async resolve(type, name) { + return type === 'permission' && names.includes(name) ? entry(name) : undefined; + }, + async list(type) { + return type === 'permission' ? names.map(entry) : []; + }, + }; +} + +async function packageObjects(): Promise { + let manifest: any; + const ctx: any = { + getService: (name: string) => { + if (name === 'manifest') return { register: (m: any) => { manifest = m; } }; + throw new Error(`no service '${name}'`); + }, + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + }; + await new ApprovalsServicePlugin().init(ctx); + return manifest.objects; +} + +const ON = { excludeSubmitter: true }; +const FIELD_STEP = [{ type: 'field', value: 'category_manager' }]; +const POSITION_STEP = [{ type: 'position', value: 'category_manager' }]; + +describe('[#25573] excludeSubmitter keeps the submitter from approving their own request', () => { + let engine: ObjectQL; + let svc: ApprovalService; + let now = Date.parse('2026-10-24T09:00:00.000Z'); + + /** Open a step on a fresh admission whose category manager is `manager`. */ + const open = async ( + key: string, approvers: any[], extra: Record = {}, + { org = ORG, manager = 'u_sub' }: { org?: string; manager?: string } = {}, + ): Promise => { + const record = { id: `ADM_${key}`, title: key, category_manager: manager }; + await engine.insert('srm_admission', record, { context: SYSTEM } as any); + return svc.openNodeRequest({ + object: 'srm_admission', recordId: record.id, runId: `run_${key}`, nodeId: 'category_review', + flowName: 'admission', config: { approvers, behavior: 'first_response', ...extra } as any, + submitterId: 'u_sub', record, organizationId: org, + }, as('u_sub', org)); + }; + const actionsOf = async (requestId: string): Promise => engine.find('sys_approval_action', { + where: { request_id: requestId }, context: SYSTEM, + }); + const notesOf = async (requestId: string) => + (await actionsOf(requestId)).filter((a) => a.action === 'comment' && !a.actor_id).map((a) => a.comment); + const snapshotOf = async (requestId: string) => { + const [row]: any[] = await engine.find('sys_approval_request', { where: { id: requestId }, context: SYSTEM }); + return JSON.parse(row.node_config_json); + }; + const EXCLUDED = "The request's submitter was removed from its approvers because this step sets excludeSubmitter"; + + beforeAll(async () => { + engine = new ObjectQL(); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, + }), true); + await engine.init(); + const defs = [ + objectDef('sys_user', ['name', 'email']), + objectDef('sys_member', ['user_id', 'organization_id', 'role']), + objectDef('sys_user_position', ['user_id', 'position', 'organization_id']), + objectDef('sys_user_permission_set', ['user_id', 'permission_set', 'organization_id']), + objectDef('srm_admission', ['title', 'category_manager']), + ...(await packageObjects()), + ]; + for (const def of defs) engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); + await engine.syncSchemas(); + await relaxTenantColumns(engine, ['sys_user', 'srm_admission']); + bindSecurityCatalogReader(engine, catalogServing(['organization_admin', 'organization_admin_no_bypass'])); + for (const u of USERS) await engine.insert('sys_user', u, { context: SYSTEM } as any); + for (const [i, [user_id, organization_id, role]] of MEMBERS.entries()) { + await engine.insert('sys_member', { id: `m_${i}`, user_id, organization_id, role }, { context: SYSTEM } as any); + } + for (const [i, [user_id, permission_set, organization_id]] of GRANTS.entries()) { + await engine.insert('sys_user_permission_set', { id: `ups_${i}`, user_id, permission_set, organization_id }, + { context: SYSTEM } as any); + } + for (const [i, [user_id, position, organization_id]] of POSITIONS.entries()) { + await engine.insert('sys_user_position', { id: `pos_${i}`, user_id, position, organization_id }, + { context: SYSTEM } as any); + } + svc = new ApprovalService({ + engine: engine as any, + clock: { now: () => new Date(now += 1000) }, + logger: { warn: () => {}, info: () => {}, error: () => {}, debug: () => {} } as any, + }); + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + describe('at the open', () => { + it('a field step that resolves to the submitter opens on the administrators, never on the submitter', async () => { + const row = await open('field', FIELD_STEP, ON); + expect([...row.pending_approvers].sort()).toEqual(['u_admin', 'u_owner']); + expect(await notesOf(row.id)).toEqual([ + "Routed to this organization's administrators because the approver slate named no one: the only approver " + + "it resolved to was the request's submitter, whom excludeSubmitter removes.", + `${EXCLUDED}; this organization's administrators decide it.`, + ]); + }); + + it('a position step held only by the submitter opens on the declared fallbackApprovers', async () => { + const row = await open('position', POSITION_STEP, { + ...ON, onEmptyApprovers: 'fallback', fallbackApprovers: [{ type: 'user', value: 'u_fallback' }], + }); + expect(row.pending_approvers).toEqual(['u_fallback']); + expect(await notesOf(row.id)).toEqual([`${EXCLUDED}; the declared fallbackApprovers decide it.`]); + expect((await snapshotOf(row.id)).__submitterExclusionNote).toMatchObject({ route: 'fallback' }); + }); + + it("onEmptyApprovers: 'fail' refuses, naming the exclusion", async () => { + await expect(open('fail', FIELD_STEP, { ...ON, onEmptyApprovers: 'fail' })).rejects.toThrow( + /^NO_APPROVERS: .* other than the request's submitter, whom the node's excludeSubmitter removes, /, + ); + }); + + it('a slate minus the submitter keeps everyone else, and the per_group tally reads only them', async () => { + const row = await open('shared', [ + { type: 'position', value: 'buyer', group: 'buying' }, + { type: 'position', value: 'procurement_director', group: 'direction' }, + ], { ...ON, behavior: 'per_group' }); + expect([...row.pending_approvers].sort()).toEqual(['u_alice', 'u_dir']); + expect((await snapshotOf(row.id)).__approverGroups).toEqual({ u_alice: ['buying'], u_dir: ['direction'] }); + expect(await notesOf(row.id)).toEqual([`${EXCLUDED}; the remaining approvers decide it.`]); + }); + + it("the submitter's email slot and an out-of-office hop onto the submitter are the submitter too", async () => { + await engine.insert('sys_approval_delegation', { + id: 'dlg_away', delegator_id: 'u_away', delegate_id: 'u_sub', reason: 'leave', organization_id: ORG, + valid_from: '2026-09-01T00:00:00.000Z', valid_until: '2026-12-31T00:00:00.000Z', + created_at: '2026-09-01T00:00:00.000Z', updated_at: '2026-09-01T00:00:00.000Z', + }, { context: SYSTEM } as any); + const row = await open('addresses', [ + { type: 'user', value: 'u_sub@example.com' }, + { type: 'user', value: 'u_away' }, + { type: 'user', value: 'u_bob' }, + ], ON); + expect(row.pending_approvers).toEqual(['u_bob']); + }); + + it('the fallback slate is screened too: one naming only the submitter degrades to the administrators', async () => { + const row = await open('fallback_self', POSITION_STEP, { + ...ON, onEmptyApprovers: 'fallback', fallbackApprovers: [{ type: 'position', value: 'treasury' }], + }); + expect([...row.pending_approvers].sort()).toEqual(['u_admin', 'u_owner']); + expect((await snapshotOf(row.id)).__submitterExclusionNote).toMatchObject({ route: 'administrators' }); + }); + + it('an organization whose only administrator is the submitter opens on the slate that names no one', async () => { + const row = await open('solo', FIELD_STEP, ON, { org: SOLO_ORG }); + expect(row.pending_approvers).toEqual([]); + expect(await notesOf(row.id)).toEqual([ + `${EXCLUDED}; no one else could take it, so only a privileged override can decide it.`, + ]); + // …and the submitter, that organization's administrator, cannot take it + // through the override either. + await expect(svc.decide(row.id, { decision: 'approve', actorId: 'u_sub' } as any, + as('u_sub', SOLO_ORG, ['organization_admin']))).rejects.toThrow(/^FORBIDDEN: 'u_sub' submitted this request/); + }); + + it('the note is data the approvals door renders in the reader\'s language', async () => { + const i18n = createMemoryI18n(); + for (const [locale, data] of Object.entries(ApprovalsTranslations)) { + i18n.loadTranslations(locale, data as Record); + } + const reader = new ApprovalService({ + engine: engine as any, + clock: { now: () => new Date(now += 1000) }, + logger: { warn: () => {}, info: () => {}, error: () => {}, debug: () => {} } as any, + i18nServiceSource: () => i18n, + }); + const row = await open('zh', [{ type: 'position', value: 'buyer' }], ON); + const served = await reader.listActions(row.id, { ...SUB, locale: 'zh-CN' }); + expect(served.filter((a) => a.action === 'comment' && !a.actor_id).map((a) => a.comment)) + .toEqual(['本步骤设置了 excludeSubmitter,提交人已从审批人中移除;由其余审批人决定。']); + // A read, never a write. + expect(await notesOf(row.id)).toEqual([`${EXCLUDED}; the remaining approvers decide it.`]); + }); + }); + + describe('after the open', () => { + it("the decision refuses the submitter's approval and keeps their rejection", async () => { + const row = await open('decide', [{ type: 'user', value: 'u_bob' }], ON); + // A slot in the submitter's hands, the way a later staffing puts it there. + await engine.update('sys_approval_request', { id: row.id, pending_approvers: 'u_sub,u_bob' }, + { context: SYSTEM } as any); + await expect(svc.decide(row.id, { decision: 'approve', actorId: 'u_sub' } as any, SUB)) + .rejects.toThrow(/^FORBIDDEN: 'u_sub' submitted this request, and its approval step sets excludeSubmitter/); + expect((await actionsOf(row.id)).map((a) => a.action)).toEqual(['submit']); + const out = await svc.decide(row.id, { decision: 'reject', actorId: 'u_sub' } as any, SUB); + expect(out.request.status).toBe('rejected'); + }); + + it('the reassign door refuses to hand the slot back to the submitter, by id or by a position only they hold', async () => { + const row = await open('reassign', [{ type: 'user', value: 'u_bob' }], ON); + const bob = as('u_bob', ORG); + for (const to of ['u_sub', 'u_sub@example.com', 'position:category_manager']) { + await expect(svc.reassign(row.id, { actorId: 'u_bob', to } as any, bob), to) + .rejects.toThrow(/^VALIDATION_FAILED: cannot reassign to .*the request's submitter, and this approval step sets excludeSubmitter/); + } + // A position the submitter shares hands the slot to the others. + const moved = await svc.reassign(row.id, { actorId: 'u_bob', to: 'position:buyer' } as any, bob); + expect(moved.request.pending_approvers).toEqual(['position:buyer']); + }); + + it('an SLA escalation leaves the submitter off the new slate, and one onto the submitter alone is not applied', async () => { + const overdue = async (row: any) => engine.update('sys_approval_request', + { id: row.id, created_at: new Date(now - 10 * 3600_000).toISOString() }, { context: SYSTEM } as any); + const shared = await open('esc_shared', [{ type: 'user', value: 'u_bob' }], { + ...ON, escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'buyer', notifySubmitter: false }, + }); + const alone = await open('esc_alone', [{ type: 'user', value: 'u_bob' }], { + ...ON, escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'u_sub', notifySubmitter: false }, + }); + await overdue(shared); + await overdue(alone); + await svc.runEscalations(); + expect((await svc.getRequest(shared.id, SYSTEM))?.pending_approvers).toEqual(['u_alice']); + expect((await svc.getRequest(alone.id, SYSTEM))?.pending_approvers).toEqual(['u_bob']); + expect((await actionsOf(alone.id)).find((a) => a.action === 'escalate')?.comment).toContain( + "not applied: the target is the request's submitter, whom the step's excludeSubmitter keeps off the slate", + ); + }); + }); + + describe('controls', () => { + it('the option off: the submitter stays on the slate, no note, and their approval stands', async () => { + const row = await open('off', FIELD_STEP); + expect(row.pending_approvers).toEqual(['u_sub']); + expect(await notesOf(row.id)).toEqual([]); + const out = await svc.decide(row.id, { decision: 'approve', actorId: 'u_sub' } as any, SUB); + expect(out.request.status).toBe('approved'); + }); + + it('the option on with a slate that never named the submitter: unchanged, and no note', async () => { + const row = await open('elsewhere', FIELD_STEP, ON, { manager: 'u_bob' }); + expect(row.pending_approvers).toEqual(['u_bob']); + expect(await notesOf(row.id)).toEqual([]); + expect((await snapshotOf(row.id)).__submitterExclusionNote).toBeUndefined(); + const out = await svc.decide(row.id, { decision: 'approve', actorId: 'u_bob' } as any, as('u_bob', ORG)); + expect(out.request.status).toBe('approved'); + }); + + it('the option off: the reassign door hands the slot to the submitter as before', async () => { + const row = await open('reassign_off', [{ type: 'user', value: 'u_bob' }]); + const moved = await svc.reassign(row.id, { actorId: 'u_bob', to: 'u_sub' } as any, as('u_bob', ORG)); + expect(moved.request.pending_approvers).toEqual(['u_sub']); + }); + }); +}); From 357ad56d5fdd7ddee6940402361a069c9e4b2e27 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 23:26:46 +0000 Subject: [PATCH 3/5] =?UTF-8?q?docs(spec):=20excludeSubmitter=20is=20read?= =?UTF-8?q?=20by=20the=20approval=20engine=20=E2=80=94=20drop=20the=20plan?= =?UTF-8?q?ned=20marker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01LXX9EBHog6ZMo6apV2jvXX Co-authored-by: Claude --- .../docs/references/automation/approval.mdx | 2 +- .../src/submitter-exclusion-note.test.ts | 56 +++++++++++++++++++ packages/spec/liveness/flow.json | 2 +- .../approval-exclude-submitter-25609.test.ts | 11 ++-- packages/spec/src/automation/approval.zod.ts | 22 ++++---- 5 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 packages/plugins/plugin-approvals/src/submitter-exclusion-note.test.ts diff --git a/content/docs/references/automation/approval.mdx b/content/docs/references/automation/approval.mdx index 4dab0c1a224..d8129c9d603 100644 --- a/content/docs/references/automation/approval.mdx +++ b/content/docs/references/automation/approval.mdx @@ -69,7 +69,7 @@ const result = ApprovalDecision.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **approvers** | `{ type: Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'field' \| 'expression' \| …>; value?: string; resolveAs?: Enum<'user' \| 'department' \| 'position' \| 'team'>; group?: string; … }[]` | ✅ | Allowed approvers for this node | -| **excludeSubmitter** | `boolean` | optional | [PLANNED — not yet enforced] Segregation of duties for this step: when true, the request's submitter (the user whose action started the flow run, recorded on the approval request as `submitter_id`) is removed from the slate `approvers` resolves to, whatever approver type produced them. If that leaves nobody, the request takes the node's `onEmptyApprovers` route (`admin_rescue` by default, or `fallbackApprovers` under `'fallback'`) and never passes silently, and the approval record shows that the exclusion changed the route. `onEmptyApprovers: 'auto_approve'` is refused beside `excludeSubmitter: true`, because it would approve exactly the request this option exists to stop; choose `'admin_rescue'`, `'fail'` or `'fallback'`. Omitted or false (the default), the slate stays as resolved. The approval engine does not read this key yet, so today the submitter is NOT removed: keep any guard you rely on (an `expression` approver or a validation rule) until it does. | +| **excludeSubmitter** | `boolean` | optional | Segregation of duties for this step: when true, the request's submitter (the user whose action started the flow run, recorded on the approval request as `submitter_id`) is removed from the slate `approvers` resolves to, whatever approver type produced them. If that leaves nobody, the request takes the node's `onEmptyApprovers` route (`admin_rescue` by default, or `fallbackApprovers` under `'fallback'`) and never passes silently, and the approval record shows that the exclusion changed the route. `onEmptyApprovers: 'auto_approve'` is refused beside `excludeSubmitter: true`, because it would approve exactly the request this option exists to stop; choose `'admin_rescue'`, `'fail'` or `'fallback'`. Omitted or false (the default), the slate stays as resolved. | | **behavior** | `Enum<'first_response' \| 'unanimous' \| 'quorum' \| 'per_group'>` | optional (default: `"first_response"`) | How to combine multiple approvers | | **minApprovals** | `integer` | optional | Approvals required — total (quorum) or per group (per_group). Omitted ⇒ all resolvable approvers for quorum, 1 per group for per_group | | **lockRecord** | `boolean` | optional (default: `true`) | Lock the record from editing while pending | diff --git a/packages/plugins/plugin-approvals/src/submitter-exclusion-note.test.ts b/packages/plugins/plugin-approvals/src/submitter-exclusion-note.test.ts new file mode 100644 index 00000000000..60092973325 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/submitter-exclusion-note.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25573] The submitter exclusion note: one English source, every shipped + * language from the same catalog, and a strict read of the stored shape. The + * end-to-end half (the note an opened request writes, and the door a zh-CN + * reader reads it through) is `exclude-submitter.integration.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { submitterExclusionNoteOf, submitterExclusionNoteText, type SubmitterExclusionRoute } from './submitter-exclusion-note.js'; +import { adminRescueNoteOf, adminRescueReason, type ApprovalSentenceRender } from './admin-rescue-note.js'; +import { approvalMessages } from './translations/messages.js'; + +const renderIn = (locale: keyof typeof approvalMessages): ApprovalSentenceRender => + (key) => (approvalMessages[locale] as Record)[key.replace(/^messages\./, '')]; + +const ROUTES: SubmitterExclusionRoute[] = ['remaining_approvers', 'fallback', 'administrators', 'no_one']; + +describe('[#25573] the submitter exclusion note', () => { + it('each route has its own English sentence, and every shipped language renders each one', () => { + const english = ROUTES.map((route) => submitterExclusionNoteText({ route })); + expect(new Set(english).size).toBe(ROUTES.length); + for (const text of english) expect(text).toMatch(/^The request's submitter was removed from its approvers/); + for (const locale of Object.keys(approvalMessages) as Array) { + for (const route of ROUTES) { + expect(submitterExclusionNoteText({ route }, renderIn(locale)), `${locale} ${route}`).toBeTruthy(); + } + } + expect(submitterExclusionNoteText({ route: 'fallback' }, () => undefined)).toBeUndefined(); + }); + + it('reads only the shape it writes', () => { + const good = { action_id: 'aact_1', route: 'administrators' }; + expect(submitterExclusionNoteOf({ __submitterExclusionNote: good })).toEqual(good); + for (const bad of [ + undefined, + {}, + { __submitterExclusionNote: 'The request…' }, + { __submitterExclusionNote: { route: 'fallback' } }, + { __submitterExclusionNote: { action_id: 'aact_1', route: 'auto_approve' } }, + ]) { + expect(submitterExclusionNoteOf(bad), JSON.stringify(bad)).toBeUndefined(); + } + }); + + it("the administrators' note names the exclusion instead of saying no approver resolved to a person", () => { + expect(adminRescueReason([], undefined, true)) + .toBe("the only approver it resolved to was the request's submitter, whom excludeSubmitter removes"); + expect(adminRescueReason([], undefined)).toBe('no approver on the node resolved to a person'); + expect(adminRescueNoteOf({ __adminRescueNote: { action_id: 'a', abandoned: [], submitter_excluded: true } })) + .toEqual({ action_id: 'a', abandoned: [], submitter_excluded: true }); + expect(adminRescueNoteOf({ __adminRescueNote: { action_id: 'a', abandoned: [], submitter_excluded: 'yes' } })) + .toBeUndefined(); + }); +}); diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json index 28137564179..3cff02c689f 100644 --- a/packages/spec/liveness/flow.json +++ b/packages/spec/liveness/flow.json @@ -1,6 +1,6 @@ { "type": "flow", - "_note": "FlowSchema. Seeded from docs/audits/2026-06-flowschema-property-liveness.md. Consumers: service-automation engine + node executors. runAs is marker-driven experimental (spec). CORRECTED 2026-07: the previous note claimed 'status/active gate nothing' — that was true when the audit ran but became FALSE with commit 497bda853 (2026-07-05, 'honor flow status for enable/disable'). `status` now gates binding AND execution; `active` remains a deprecated no-op. 2026-07-30 (#3896 close-out sweep): the dead authoring keys were REMOVED — tombstoned at the schema with prescriptions (retiredKey) and stripped by the protocol-17 close-out conversions; entries deleted per the #3715 precedent. 2026-08-28 (commit 8f10a79f7): the four `path:NNN` citations in this file were re-anchored to their consuming symbols. All four were wrong, all four IN RANGE, and `status` also carried three bare line suffixes with no path that no check has ever resolved. PLANNED RECORD (#25609, 2026-10-24, ruling 6377731715 on #25496, letter B for #25446, \"default off\"): an approval node's `config.excludeSubmitter` (ApprovalNodeConfigSchema in packages/spec/src/automation/approval.zod.ts), optional and absent when off, removes the request's submitter (`sys_approval_request.submitter_id`, the run's trigger user) from the slate `approvers` resolves to, for every approver type; an emptied slate takes the node's `onEmptyApprovers` route, never a silent pass, and the approval record shows that the exclusion changed the route. PLANNED, not live: measured at f7b0298968, plugin-approvals parses the key with the rest of the node config (packages/plugins/plugin-approvals/src/approval-node.ts, the ApprovalNodeConfigSchema.safeParse of node.config) and nothing reads it, so the submitter is not removed. Carrier: #25573 (plugin-approvals, Blocked-by #25609). No row carries it: the walk ends at `nodes.config`, a `z.record(z.string(), z.unknown())` that childShape() does not descend — a `children` map planted there is refused, exit 1, as `flow/nodes.config (declared children but property is not a container)` (measured on #25609's branch) — and the liveness lint reads no deeper than a container's direct children, so no `authorWarn` row can reach this key's authors either. The checkable record is the authorable-surface `automation/ApprovalNodeConfig:excludeSubmitter` row and the key's own describe, which opens with `[PLANNED — not yet enforced]` and ends by telling the author the submitter is not removed yet. When #25573 lands, that marker and that last sentence leave and this sentence is rewritten.", + "_note": "FlowSchema. Seeded from docs/audits/2026-06-flowschema-property-liveness.md. Consumers: service-automation engine + node executors. runAs is marker-driven experimental (spec). CORRECTED 2026-07: the previous note claimed 'status/active gate nothing' — that was true when the audit ran but became FALSE with commit 497bda853 (2026-07-05, 'honor flow status for enable/disable'). `status` now gates binding AND execution; `active` remains a deprecated no-op. 2026-07-30 (#3896 close-out sweep): the dead authoring keys were REMOVED — tombstoned at the schema with prescriptions (retiredKey) and stripped by the protocol-17 close-out conversions; entries deleted per the #3715 precedent. 2026-08-28 (commit 8f10a79f7): the four `path:NNN` citations in this file were re-anchored to their consuming symbols. All four were wrong, all four IN RANGE, and `status` also carried three bare line suffixes with no path that no check has ever resolved. LIVE RECORD (#25609 declared it, #25573 reads it; ruling 6377731715 on #25496, letter B for #25446, \"default off\"): an approval node's `config.excludeSubmitter` (ApprovalNodeConfigSchema in packages/spec/src/automation/approval.zod.ts), optional and absent when off, removes the request's submitter (`sys_approval_request.submitter_id`, the run's trigger user) from the slate `approvers` resolves to, for every approver type; an emptied slate takes the node's `onEmptyApprovers` route, never a silent pass, and the approval record shows that the exclusion changed the route. Consumer: plugin-approvals (packages/plugins/plugin-approvals/src/approval-service.ts): `openNodeRequest` screens the `approvers`, `fallback` and `admin_rescue` slates through `excludedSubmitterSlots` and writes the `__submitterExclusionNote` route note, and `decideNode`, `reassign` and the SLA `escalateRequest` keep the slot out of the submitter's hands after the open. No row carries it: the walk ends at `nodes.config`, a `z.record(z.string(), z.unknown())` that childShape() does not descend — a `children` map planted there is refused, exit 1, as `flow/nodes.config (declared children but property is not a container)` (measured on #25609's branch) — and the liveness lint reads no deeper than a container's direct children, so no `authorWarn` row can reach this key's authors either. The checkable record is the authorable-surface `automation/ApprovalNodeConfig:excludeSubmitter` row and the key's own describe.", "props": { "name": { "status": "live", diff --git a/packages/spec/src/automation/approval-exclude-submitter-25609.test.ts b/packages/spec/src/automation/approval-exclude-submitter-25609.test.ts index 9e53e6b536f..b45c341a058 100644 --- a/packages/spec/src/automation/approval-exclude-submitter-25609.test.ts +++ b/packages/spec/src/automation/approval-exclude-submitter-25609.test.ts @@ -24,8 +24,8 @@ * - §4 THE DESIGNER'S SCHEMA: the published JSON schema carries the key as a * plain optional boolean with no `default`, under the same * `additionalProperties: false`. - * - §5 THE STATUS: the describe opens with the `[PLANNED` marker (the runtime - * half has not landed, so the key is declared and not yet read) and names + * - §5 THE STATUS: the describe carries no `[PLANNED` marker (the runtime + * half, #25573, reads the key in `plugin-approvals`) and names * the subjects an author needs — `submitter_id` (who is removed), * `onEmptyApprovers` (where an emptied slate goes) and `'auto_approve'` (the * one policy refused beside it, §6). The prose itself is not pinned. @@ -189,7 +189,7 @@ describe('§4 the published JSON schema carries it as an optional boolean with n // §5 the status the describe declares // ─────────────────────────────────────────────────────────────────────────── -describe('§5 the describe declares the key planned and names its two subjects', () => { +describe('§5 the describe declares the key live and names its two subjects', () => { // Read inside each test, never at collection: a key that went missing then // reds these two tests instead of erroring the file and hiding §1–§4. const doc = (): string => { @@ -198,8 +198,9 @@ describe('§5 the describe declares the key planned and names its two subjects', return member?.description ?? ''; }; - it('opens with the planned marker: declared, not yet read by the approval engine', () => { - expect(doc().startsWith('[PLANNED')).toBe(true); + it('carries no planned marker: the approval engine reads the key (#25573)', () => { + expect(doc()).toMatch(/^Segregation of duties/); + expect(doc()).not.toContain('PLANNED'); }); it('names who is removed (submitter_id) and where an emptied slate goes (onEmptyApprovers)', () => { diff --git a/packages/spec/src/automation/approval.zod.ts b/packages/spec/src/automation/approval.zod.ts index 8c310f2bea7..c860b8b8f34 100644 --- a/packages/spec/src/automation/approval.zod.ts +++ b/packages/spec/src/automation/approval.zod.ts @@ -951,26 +951,24 @@ export const ApprovalNodeConfigSchema = lazySchema(() => strictObject( * unchanged — "off, behaviour is unchanged" holds at the parse as well as * at the run. * - * PLANNED, not yet read: `plugin-approvals` parses the key with the rest of - * the config and nothing reads it yet, so the submitter is NOT removed until - * #25573 lands. The liveness record is the `liveness/flow.json` `_note` - * (the ledger walk ends at `flow/nodes.config`, a record of `unknown`, so - * no row can carry a node-config key) and the `[PLANNED — not yet - * enforced]` marker this describe opens with; when #25573 lands, the marker - * and the describe's last sentence leave and the `_note` sentence is - * rewritten. + * Read by `plugin-approvals` (#25573): `openNodeRequest` takes the + * submitter off every slate the request can open on (`approvers`, the + * `fallback` slate and the `admin_rescue` administrators) and writes the + * route note, and the decision, the reassign door and the SLA escalation + * keep the slot out of the submitter's hands after the open. The liveness + * record is the `liveness/flow.json` `_note` (the ledger walk ends at + * `flow/nodes.config`, a record of `unknown`, so no row can carry a + * node-config key). */ excludeSubmitter: z.boolean().optional().describe( - '[PLANNED — not yet enforced] Segregation of duties for this step: when true, the request\'s submitter ' + 'Segregation of duties for this step: when true, the request\'s submitter ' + '(the user whose action started the flow run, recorded on the approval request as `submitter_id`) is ' + 'removed from the slate `approvers` resolves to, whatever approver type produced them. If that leaves ' + 'nobody, the request takes the node\'s `onEmptyApprovers` route (`admin_rescue` by default, or ' + '`fallbackApprovers` under `\'fallback\'`) and never passes silently, and the approval record shows that ' + 'the exclusion changed the route. `onEmptyApprovers: \'auto_approve\'` is refused beside ' + '`excludeSubmitter: true`, because it would approve exactly the request this option exists to stop; ' - + 'choose `\'admin_rescue\'`, `\'fail\'` or `\'fallback\'`. Omitted or false (the default), the slate stays as resolved. ' - + 'The approval engine does not read this key yet, so today the submitter is NOT removed: keep any guard ' - + 'you rely on (an `expression` approver or a validation rule) until it does.', + + 'choose `\'admin_rescue\'`, `\'fail\'` or `\'fallback\'`. Omitted or false (the default), the slate stays as resolved.', ), /** From 28b6d27e79b2ca0531031b4f0ed2c018d39c4e7c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 23:30:23 +0000 Subject: [PATCH 4/5] chore: changeset for excludeSubmitter runtime; drop an unused test binding Claude-Session: https://claude.ai/code/session_01LXX9EBHog6ZMo6apV2jvXX Co-authored-by: Claude --- .changeset/25573-exclude-submitter-runtime.md | 18 ++++++++++++++++++ .../src/exclude-submitter.integration.test.ts | 1 - 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 .changeset/25573-exclude-submitter-runtime.md diff --git a/.changeset/25573-exclude-submitter-runtime.md b/.changeset/25573-exclude-submitter-runtime.md new file mode 100644 index 00000000000..e1748f4cb9f --- /dev/null +++ b/.changeset/25573-exclude-submitter-runtime.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-approvals': patch +'@objectstack/spec': patch +--- + +feat(plugin-approvals): an approval step's `excludeSubmitter: true` keeps the submitter from approving their own request + +Clause-②: no +Metadata-protocol: none + +- **Before.** `excludeSubmitter` was declared on the approval node config and its describe opened with `[PLANNED — not yet enforced]`: the approval engine parsed it and read nothing, so a category manager who submitted their own admission was step 1's `field` approver and approved it. +- **At the open.** The submitter (the run's trigger user, `sys_approval_request.submitter_id`) is taken off the resolved slate, whatever approver type produced them, by their user id or their account's email, an out-of-office hop that lands on them included. Everyone else stays on it, per_group tags included. A slate this empties takes the node's `onEmptyApprovers` route: the organization's administrators by default, the declared `fallbackApprovers` under `'fallback'`, or the `NO_APPROVERS` refusal under `'fail'`, which now names the exclusion. The fallback slate and the administrators' slate are screened the same way, so an organization whose only administrator is the submitter opens the request on the slate that names no one. +- **On the record.** A request whose slate lost its submitter carries a system note on its timeline naming the route it took (the remaining approvers, the fallback approvers, the administrators, or no one), rendered in the reader's language by the approvals door. The administrators' routing note says the only approver was the submitter instead of "no approver resolved to a person". +- **After the open.** An approval decided by the submitter is refused with `FORBIDDEN`, the privileged override included; their rejection stands. A reassign to the submitter, or to a position only they hold, is refused with `VALIDATION_FAILED`. An SLA escalation that reassigns leaves them off the new slate, and one whose only target is the submitter is not applied: the pending approvers keep the request and are told the SLA lapsed. +- **Spec.** The describe drops the `[PLANNED — not yet enforced]` marker and its last sentence; nothing else in the schema changes. +- **Unchanged.** With the option off or omitted (the default), every slate, timeline, decision, reassign and escalation behaves as before. + +What to do: nothing to migrate. A step that set `excludeSubmitter: true` now enforces it; an `expression` approver or validation rule added by hand to keep the submitter off can be removed. diff --git a/packages/plugins/plugin-approvals/src/exclude-submitter.integration.test.ts b/packages/plugins/plugin-approvals/src/exclude-submitter.integration.test.ts index 6bf72b02ce9..78c123403fc 100644 --- a/packages/plugins/plugin-approvals/src/exclude-submitter.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/exclude-submitter.integration.test.ts @@ -60,7 +60,6 @@ const SOLO_ORG = 'org_solo'; const as = (userId: string, org: string, permissions: string[] = []) => ({ userId, tenantId: org, positions: [], permissions }) as any; const SUB = as('u_sub', ORG); -const OWNER = as('u_owner', ORG, ['organization_admin']); const USERS = ['u_sub', 'u_owner', 'u_admin', 'u_alice', 'u_bob', 'u_fallback', 'u_dir', 'u_away'] .map((id) => ({ id, name: id, email: `${id}@example.com` })); From 0aed0b90bb7cc68dac571fc160d5331aa6351c40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 23:44:35 +0000 Subject: [PATCH 5/5] docs(audits): tenant-audit census counts the route note's sys_approval_action insert Claude-Session: https://claude.ai/code/session_01LXX9EBHog6ZMo6apV2jvXX Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 32 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 18 +++++------ 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 49a7a7d16a6..a278e7a67ee 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **51 of the 238 sites are spelled that way**. A +forwarding shim cannot, and **51 of the 239 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **238** | +| 175 write call sites | quoted in the merged changeset | **239** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **162 of 238** decidable, **76** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 135 decidably elevated, 0 decidably not, 95 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **163 of 239** decidable, **76** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 136 decidably elevated, 0 decidably not, 95 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -217,11 +217,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -135 of 238 (57%) as decidably elevated, with 95 more whose elevation is a +136 of 239 (57%) as decidably elevated, with 95 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 238`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 239`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **29 further sites** have an options argument this @@ -233,28 +233,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **238** | -| …whose object name is statically decidable | 162 | +| write call sites on the application surface | **239** | +| …whose object name is statically decidable | 163 | | …whose object name is chosen at run time | 76 | -| …against an object with tenancy ENABLED | 161 | +| …against an object with tenancy ENABLED | 162 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 179 | +| threading a tenant context | 180 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 51 | | …of those, against a decidably tenancy-enabled object | 29 | -| threading a decidably ELEVATED (`isSystem`) context | 135 | +| threading a decidably ELEVATED (`isSystem`) context | 136 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 95 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 183 | +| receiver carried a readable engine type | 184 | | receiver erased, placed by the object NAME | 45 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 111 | +| object name spelled inline | 112 | | object name spelled through a `const` | 51 | | object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 69 | @@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-23 at `487d57fed`. +Measured on 2026-10-24 at `28b6d27e7`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 723 | +| tracked non-test sources scanned | 767 | | engine-shaped types recognised | 87 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 174 | +| same-named calls subtracted as non-engine | 176 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 7957e04a13b..b91fc9f0dc1 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 238 | -| Object name statically decidable | 162 | +| Write call sites | 239 | +| Object name statically decidable | 163 | | Object name chosen at run time | 76 | -| Against a tenancy-enabled object | 161 | +| Against a tenancy-enabled object | 162 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 179 | +| Threading a tenant context | 180 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 51 | | …and decidably tenancy-enabled | 29 | -| Threading a decidably elevated context | 135 | +| Threading a decidably elevated context | 136 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 95 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-23 at `487d57fed`. +Measured on 2026-10-24 at `28b6d27e7`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 723 | +| tracked non-test sources scanned | 767 | | engine-shaped types recognised | 87 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 174 | +| same-named calls subtracted as non-engine | 176 | ## Every site @@ -106,7 +106,7 @@ Measured on 2026-10-23 at `487d57fed`. | `packages/plugins/organizations/src/claim-org-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `packages/plugins/plugin-approvals/src/action-slot-backfill.ts` | `update` | `sys_approval_action` | enabled | elevated | 2 | | `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 15 | +| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 16 | | `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 | | `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_approver` | enabled | elevated | 2 | | `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_request` | enabled | elevated | 1 |