Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f05f81b
feat(plugin-approvals): engine-written display labels for the approva…
claude Oct 16, 2026
9e371fd
feat(plugin-approvals): translate the new approval-list display columns
claude Oct 16, 2026
56513b6
test(plugin-approvals): pin the engine-written approval-list display …
claude Oct 16, 2026
790b379
chore(changeset): plugin-approvals approval-list display labels
claude Oct 16, 2026
d168175
Merge remote-tracking branch 'origin/main' into claude/issue-23889-ap…
claude Oct 16, 2026
c907710
refactor(plugin-approvals): keep each decision-log insert at its call…
claude Oct 16, 2026
0bb3161
feat(plugin-approvals): boot-time backfill of the approval-list displ…
claude Oct 16, 2026
e1a23c7
test(plugin-approvals): pin the display-label backfill, the failed-re…
claude Oct 16, 2026
eebddf3
chore(changeset): plugin-approvals display labels — the boot-time fil…
claude Oct 16, 2026
e415353
Merge remote-tracking branch 'origin/main' into claude/issue-23889-ap…
claude Oct 16, 2026
4d9ca5a
test(plugin-approvals): classify the display backfill's acted_as pres…
claude Oct 16, 2026
22b2179
test(plugin-approvals): pin the display backfill's null-only writes o…
claude Oct 16, 2026
2f4ee5e
docs(census): tenant-audit census counts the display backfill's two S…
claude Oct 16, 2026
b74901e
test(plugin-approvals): type the failing-engine double's find options…
claude Oct 16, 2026
28c5d1e
fix(plugin-approvals): the boot backfills keep each row's updated_at;…
claude Oct 16, 2026
109541c
test(plugin-approvals): pin one labeller call per backfill page
claude Oct 16, 2026
4acd459
Merge remote-tracking branch 'origin/main' into claude/issue-23889-ap…
claude Oct 16, 2026
f8efc94
test(plugin-approvals): re-classify the action-slot repair's actor_id…
claude Oct 16, 2026
18b5a33
fix(plugin-approvals): the display backfill re-reads each request bef…
claude Oct 16, 2026
f215e09
test(plugin-approvals): pin that page labels never land on a slate th…
claude Oct 16, 2026
fbd7b4d
Merge remote-tracking branch 'origin/main' into claude/issue-23889-ap…
claude Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/23889-approval-list-display-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@objectstack/plugin-approvals': minor
---

Clause-②: yes (widening)

feat(plugin-approvals): the approval lists show engine-written display labels, which are the step and flow labels, whom a pending request waits on, the record an action is about, and the slot it was taken as (#23889)

The Requests and Action History lists are generic list views. They show stored columns only, so admins used to read node ids (`manager_review`), `flow:` api names, slot addresses and raw user ids there. The approval engine now writes display columns beside the machine ones, and the list views show those columns. Nothing is removed or renamed. The machine columns (`process_name`, `current_step`, `pending_approvers`, `step_name`, `acted_as`) keep their values, and every query and decision still reads them.

**`sys_approval_request`**, written by `ApprovalService`:

- `process_label`: the flow's label, and `current_step_label`: the Approval node's label. Both are snapshots taken when the request opens, from the labels the flow engine already hands to `openNodeRequest`. Renaming the flow later does not rewrite them.
- `pending_approver_labels`: whom the request waits on, one label per pending slot, joined with `, `. A slot that names a person (a user id, or an email an account carries) gets that account's name. Any other slot (a `position:` or `team:` literal) shows its address. The column is rewritten whenever the slate is written (open, a partial tally, reassign, SLA reassign) and cleared with the slate on every terminal write.
- The list views: `process_label` replaces `process_name` in every view. `current_step_label` replaces `current_step` in `all_requests`, `my_pending` and `submitted_by_me`. `pending_approver_labels` is added to `all_requests` and `my_pending`.

**`sys_approval_action`**: each new row copies these columns from its request:

- `step_label`: the request's step label.
- `object_name` + `record_id`: the request's record pointer (`record_id` declares `referenceVia: 'object_name'`), so a list can name the record.
- `acted_as_label`: `acted_as` labelled the same way as the waiting-on column. It is null when `acted_as` is null, and also while a person slot's name could not be read (see below).
- The list views: `step_label` replaces `step_name` in `recent`, `all_actions` and `by_actor`. `recent` and `all_actions` add the record pointer and show `acted_as_label` in place of `acted_as`.

The inbox door's `process_label` / `step_label` now read the new columns first. They fall back to the config snapshot for rows opened earlier, so what they serve is unchanged.

**Read security.** Every new value is either a column of the request the row's readers can already read, or a name the approvals door already serves those readers for that request: `pending_approver_names`, and the timeline's names. The names come from the same resolver, under the same system read. The generic data door serves both objects only to the callers the approvals door would serve.

**Rows written before this release are filled at boot.** A new boot-time pass runs on `kernel:ready`, after the action-slot repair. It fills the new columns on rows that hold null. Each request is re-read just before it is written, and the write names only the columns that are still null on that re-read row. Whom a request waits on is written only while the request is still pending on the slate its names were read for. A value a live writer stored, such as the boot sweep or another node, is therefore kept. The only window left is the single engine call between the re-read and the write. Every row it fills keeps its own `updated_at`: the write passes the stored value back under `preserveAudit`, so the request lists, which sort on `updated_at`, keep their order. The action-slot repair's writes keep it the same way. Where `plugin-audit` is composed, the first boot after the upgrade writes one system entry in the audit log and one in the record's activity timeline for each row it fills. A second boot writes nothing.

- On every request, the flow and step labels come from the chain the approvals door serves: the authored labels the config snapshot carries, else the humanized machine name. The list and the inbox therefore show the same label for the same row, and no request is left blank.
- On a pending request, whom it waits on is filled.
- On every action, the step label and the record pointer are copied from its request, and its acting-as label is filled. An action whose request no longer exists keeps a null pointer.
- The action-slot repair now labels every `acted_as` it writes.

**Two details.**

- An empty authored label (the spec allows `label: ''`) is stored as the humanized machine name, never `''`.
- A name that cannot be read because the `sys_user` read failed is stored as null, never as the raw slot address. The failure is logged once at `warn`, and the boot pass fills it on the next boot. A slot that names no person still shows its address.
32 changes: 16 additions & 16 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,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 **53 of the 227 sites are spelled that way**. A
forwarding shim cannot, and **53 of the 229 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.

Expand Down Expand Up @@ -187,10 +187,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 | **227** |
| 175 write call sites | quoted in the merged changeset | **229** |
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 227** decidable, **71** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 123 decidably elevated, 0 decidably not, 96 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **158 of 229** decidable, **71** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 125 decidably elevated, 0 decidably not, 96 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
Expand All @@ -207,11 +207,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
123 of 227 (54%) as decidably elevated, with 96 more whose elevation is a
125 of 229 (55%) as decidably elevated, with 96 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 / 227`, and say what it is**: the sites whose options argument was
⇒ **Cite `2 / 229`, 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" — **32 further sites** have an options argument this
Expand All @@ -223,28 +223,28 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **227** |
| …whose object name is statically decidable | 156 |
| write call sites on the application surface | **229** |
| …whose object name is statically decidable | 158 |
| …whose object name is chosen at run time | 71 |
| …against an object with tenancy ENABLED | 155 |
| …against an object with tenancy ENABLED | 157 |
| …against an object that declares tenancy off | 1 |
| threading a tenant context | 166 |
| threading a tenant context | 168 |
| 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 | 53 |
| …of those, against a decidably tenancy-enabled object | 32 |
| threading a decidably ELEVATED (`isSystem`) context | 123 |
| threading a decidably ELEVATED (`isSystem`) context | 125 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 96 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 189 |
| receiver carried a readable engine type | 191 |
| receiver erased, placed by the object NAME | 28 |
| receiver erased, placed by an `object: string` PARAMETER | 5 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |

| object name spelled inline | 104 |
| object name spelled inline | 106 |
| object name spelled through a `const` | 52 |
| object name is an `object: string` parameter | 7 |
| object name is some other run-time expression | 64 |
Expand Down Expand Up @@ -297,12 +297,12 @@ 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-16 at `caebc6a7b`.
Measured on 2026-10-16 at `22b217924`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 655 |
| engine-shaped types recognised | 78 |
| tracked non-test sources scanned | 657 |
| engine-shaped types recognised | 79 |
| declared objects in the registry | 116 |
| same-named calls subtracted as non-engine | 165 |

Expand Down
18 changes: 10 additions & 8 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 227 |
| Object name statically decidable | 156 |
| Write call sites | 229 |
| Object name statically decidable | 158 |
| Object name chosen at run time | 71 |
| Against a tenancy-enabled object | 155 |
| Against a tenancy-enabled object | 157 |
| Against an object declaring tenancy off | 1 |
| Threading a tenant context | 166 |
| Threading a tenant context | 168 |
| Provably carrying none | 8 |
| …and decidably tenancy-enabled | 2 |
| Options argument unreadable | 53 |
| …and decidably tenancy-enabled | 32 |
| Threading a decidably elevated context | 123 |
| Threading a decidably elevated context | 125 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 96 |

Expand Down Expand Up @@ -90,12 +90,12 @@ 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-16 at `caebc6a7b`.
Measured on 2026-10-16 at `22b217924`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 655 |
| engine-shaped types recognised | 78 |
| tracked non-test sources scanned | 657 |
| engine-shaped types recognised | 79 |
| declared objects in the registry | 116 |
| same-named calls subtracted as non-engine | 165 |

Expand All @@ -115,6 +115,8 @@ Measured on 2026-10-16 at `caebc6a7b`.
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` | `update` | `objectPlan.object` | undecidable | context, elevation undecidable | 1 |
| `packages/plugins/plugin-approvals/src/display-label-backfill.ts` | `update` | `sys_approval_action` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/display-label-backfill.ts` | `update` | `sys_approval_request` | enabled | elevated | 1 |
| `packages/plugins/plugin-audit/src/activity-changed-fields-backfill.ts` | `update` | `sys_activity` | enabled | elevated | 1 |
| `packages/plugins/plugin-audit/src/auth-event-audit.ts` | `insert` | `sys_audit_log` | enabled | options unreadable | 1 |
| `packages/plugins/plugin-audit/src/comment-parent-pair-backfill.ts` | `update` | `sys_comment` | enabled | elevated | 1 |
Expand Down
55 changes: 48 additions & 7 deletions packages/plugins/plugin-approvals/src/action-slot-backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
*/

import { keysetWalk } from '@objectstack/types';
import type { SlotLabelSource } from './display-label-backfill.js';

/**
* The engine surface the repair needs — a structural subset of
Expand Down Expand Up @@ -99,8 +100,20 @@ export interface ActionSlotBackfillResult {
}

const SYSTEM_CONTEXT = { isSystem: true, positions: [], permissions: [] };
/**
* [#23889] The repair's writes keep each row's own `updated_at`: moving a slot
* between columns, or labelling it, is not an edit of the decision.
* `preserveAudit` makes the engine's update stamp and the SQL driver keep the
* value the write passes back (the read row's); a row with none passes none.
*/
const PRESERVING_CONTEXT = { ...SYSTEM_CONTEXT, preserveAudit: true };
const PAGE = 500;

/** The read row's own `updated_at`, to hand back on the write. */
function keptStamp(row: Record<string, unknown>): Record<string, unknown> {
return row.updated_at !== undefined && row.updated_at !== null ? { updated_at: row.updated_at } : {};
}

function nonEmpty(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
Expand All @@ -123,9 +136,25 @@ export function isSlotLiteral(actorId: string): boolean {
*/
export async function backfillActionSlots(
engine: ActionSlotBackfillEngine,
options: { pageSize?: number } = {},
options: { pageSize?: number; labels?: Pick<SlotLabelSource, 'slotLabels'> } = {},
): Promise<ActionSlotBackfillResult> {
const pageSize = options.pageSize ?? PAGE;
// [#23889] Every `acted_as` this repair writes gets its display label in the
// same write, so `acted_as_label` is null only where `acted_as` is (or where
// the name could not be read — the display-label backfill retries that). The
// labels of a page are read in ONE labeller call, never one per row.
const pageLabels = async (slots: string[]): Promise<Map<string, string | null>> => {
const out = new Map<string, string | null>();
const distinct = [...new Set(slots)];
if (!options.labels || !distinct.length) return out;
const answered = await options.labels.slotLabels(distinct);
distinct.forEach((slot, i) => out.set(slot, answered[i] ?? null));
return out;
};
const labelOf = (named: Map<string, string | null>, slot: string): Record<string, unknown> => {
const label = named.get(slot);
return label ? { acted_as_label: label } : {};
};
let literalsMoved = 0;
let sentinelsCleared = 0;
let votesStamped = 0;
Expand All @@ -137,7 +166,7 @@ export async function backfillActionSlots(
const nonIds = keysetWalk<Record<string, unknown>>(
(q) => engine.find('sys_approval_action', {
...q,
fields: ['id', 'actor_id', 'acted_as'],
fields: ['id', 'actor_id', 'acted_as', 'updated_at'],
context: SYSTEM_CONTEXT,
}) as Promise<Record<string, unknown>[]>,
{
Expand All @@ -146,6 +175,10 @@ export async function backfillActionSlots(
},
);
for await (const rows of nonIds.pages()) {
const named = await pageLabels(rows
.filter((row) => !nonEmpty(row.acted_as))
.map((row) => nonEmpty(row.actor_id))
.filter((v): v is string => v !== null && isSlotLiteral(v)));
for (const row of rows) {
const id = nonEmpty(row.id);
const actorId = nonEmpty(row.actor_id);
Expand All @@ -154,9 +187,9 @@ export async function backfillActionSlots(
if (!sentinel && !isSlotLiteral(actorId)) continue;
// Either way the column ends up empty. A slot also moves to `acted_as`;
// a sentinel does not — the sweep took no slot.
const patch: Record<string, unknown> = { id, actor_id: null };
if (!sentinel && !nonEmpty(row.acted_as)) patch.acted_as = actorId;
await engine.update('sys_approval_action', patch, { context: SYSTEM_CONTEXT });
const patch: Record<string, unknown> = { id, actor_id: null, ...keptStamp(row) };
if (!sentinel && !nonEmpty(row.acted_as)) Object.assign(patch, { acted_as: actorId }, labelOf(named, actorId));
await engine.update('sys_approval_action', patch, { context: PRESERVING_CONTEXT });
if (sentinel) sentinelsCleared++;
else literalsMoved++;
}
Expand All @@ -177,20 +210,28 @@ export async function backfillActionSlots(
const votes = keysetWalk<Record<string, unknown>>(
(q) => engine.find('sys_approval_action', {
...q,
fields: ['id', 'actor_id', 'acted_as'],
fields: ['id', 'actor_id', 'acted_as', 'updated_at'],
context: SYSTEM_CONTEXT,
}) as Promise<Record<string, unknown>[]>,
{ where: { request_id: { $in: ids }, action: 'approve', step_index: 0 }, pageSize },
);
for await (const rows of votes.pages()) {
const named = await pageLabels(rows
.filter((row) => !nonEmpty(row.acted_as))
.map((row) => nonEmpty(row.actor_id))
.filter((v): v is string => v !== null && !isMachineSentinel(v)));
for (const row of rows) {
const id = nonEmpty(row.id);
const actorId = nonEmpty(row.actor_id);
if (!id || !actorId || nonEmpty(row.acted_as)) continue;
// Pass 1 has already emptied every sentinel; a machine took no slot,
// so one is never stamped as a vote even if a write raced the scan.
if (isMachineSentinel(actorId)) continue;
await engine.update('sys_approval_action', { id, acted_as: actorId }, { context: SYSTEM_CONTEXT });
await engine.update(
'sys_approval_action',
{ id, acted_as: actorId, ...labelOf(named, actorId), ...keptStamp(row) },
{ context: PRESERVING_CONTEXT },
);
votesStamped++;
}
}
Expand Down
Loading
Loading