Repository navigation
feat(plugin-approvals): engine-written display labels for the approval lists (step, flow, waiting on, record pointer, acted-as) - #23984
Conversation
…l lists (WIP) Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
…columns on a real engine Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
…proval-display-labels
… site; a helper builds the row The tenant-audit census certifies every write call site, so the display columns are added by a row builder (actionRow) rather than by funnelling the fifteen inserts through one writer. Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 37 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6fe0cb4191e106e66d216b995c3c9927f1b29e1e && git checkout 6fe0cb4191e106e66d216b995c3c9927f1b29e1e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bb14934cda8d2d2c15c20a0c88b6a607efe4c23b fbd7b4d3b585cf9e001aa538e9fbee366e2cd9b9 && git checkout -B drift-repro bb14934cda8d2d2c15c20a0c88b6a607efe4c23b && git merge --no-ff fbd7b4d3b585cf9e001aa538e9fbee366e2cd9b9
node scripts/docs-audit/affected-docs.mjs --json bb14934cda8d2d2c15c20a0c88b6a607efe4c23b
|
Contract reviewServed-tier: PR #23984 for card #23889, on the declaration face: no ① Derived judgments
② Semver levelClause-②: yes (widening). The line is right.
③ Boundary flags
FAIL reasonsR1. Rows that already exist: the list views now show nothing where they showed a value, permanently, and the two doors disagree about the same row.
Observations (non-blocking)
Gates (check-runs on this head, read last, 2026-10-16T16:26Z)
Implemented-by: VERDICT: FAIL Generated by Claude Code |
席位核对 · REWORK (patch round 1) ·
|
…ay columns (WIP) Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
…ad rider and the empty-label rider Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
…l of earlier rows Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
…proval-display-labels
…ence test as a slot-vs-slot site Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
…n an action whose labels differ from its request's Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
…YSTEM_CTX write sites (227 -> 229) Regenerated with scripts/tenant-audit-census.mjs --write; the hand-written figures the gate holds to the census follow it. Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
… instead of erasing them Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
Addendum: patch round 1 (REWORK
|
| mutation | result |
|---|---|
backfill skips process_label |
red, (a) |
backfill skips the action's object_name |
red, (a) |
plugin no longer registers the backfill on kernel:ready |
red, (a) |
action step_label written even when present |
red, (b) and (c) |
action acted_as_label written even when present |
red, (c) |
| failed read stores the address | red, (d) |
backfillActionSlots writes no label |
red, (e) |
| empty authored label accepted | red, (f) |
One reading is green by construction, and I'm stating it rather than hiding it. Dropping the request-side null-only guard changes nothing, because requestDisplayLabels returns the row's own column first, so a present label is written back unchanged. The action-side guards above are the ones that can be observed, and they are pinned.
Tests and gates at b74901ee3
pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2: 81 files / 1100 tests passed. Typecheck exit 0, withcheck:test-typecheckOK and no new debt.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 97 commands at this head (the docs paths add 30 families). All 97 were run, each exit captured before any pipe: all exit 0.--ran: "97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero: every line records its exit).- Two gates were red earlier in this round and are fixed at this head:
check:tenant-audit-census: the census update above;check:query-options-erasure: the new test erased afindoptions argument (as any); it is typedEngineQueryOptionsinb74901ee3.
check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET: unbuilt dist in this fresh worktree). The packages were built, and it exits 0 in the final run.- CI on this head: read the PR's check-runs.
Generated by Claude Code
Contract reviewServed-tier: PR #23984 for card #23889. This is a same-form re-review after patch round 1, on the declaration face: the file list has no What I read:
I read the check-runs last. ① Derived judgments
② Semver levelClause-②: yes (widening). The line is right.
③ Boundary flags
FAIL reasonsR2. The fill rewrites
Observations (non-blocking)
Gates (check-runs on this head, read last, 2026-10-16T18:06Z)48 runs: 41 success, 7 skipped, none still running.
No gate reads Implemented-by: VERDICT: FAIL Generated by Claude Code |
席位核对 · REWORK (patch round 2) ·
|
… name reads batched per page Both walks of the display-label backfill and both write sites of the action-slot repair read updated_at with the page and write under preserveAudit passing it back (the comment-parent-pair backfill's shape), so a filled request keeps its place in the lists that sort on it. The labeller is called once per page. Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
…proval-display-labels
… reads (the per-page label batch reads it once per pass) Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
Addendum: patch round 2 (REWORK
|
| mutation | result |
|---|---|
request write under the plain SYSTEM context (no preserveAudit) |
red, (g) |
action write without the stored updated_at passed back |
red, (g) |
| action-slot repair pass 1 under the plain SYSTEM context | red, (e2) |
action-slot repair pass 2 without the stored updated_at |
red, (e2) |
| the request page labels one row at a time | red, (c) |
Suggested PR-body line for "Rows written before this change"
A boot-time pass on
kernel:readyfills every display column on rows written before this change: the flow and step labels by the inbox's own chain, the waiting-on column of pending requests, and each action's step label, record pointer and acting-as label. It writes only into null columns, keeps each row'supdated_at(preserveAudit, the stored value passed back), reads names once per page, and writes nothing on a second boot.
Tests and gates at f8efc941a
pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2: 81 files / 1102 tests passed. Typecheck exit 0, withcheck:test-typecheckOK and no new debt.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 97 commands at this head. All 97 were run, each exit captured before any pipe: all exit 0, includingcheck:tenant-audit-census(still 229 certified sites: this round adds no write site),check:query-options-erasure,check:i18nandcheck:dual-build-cjs-loads.--ran: "97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero).- One package-suite red came up and was fixed this round:
approver-address-readers.test.tscounted the two newrow.actor_idreads. It was caught in the full run on the merged head and re-classified inf8efc941a. - CI on this head: read the PR's check-runs.
Generated by Claude Code
Contract reviewServed-tier: PR #23984 for card #23889. This is a same-form re-review after patch round 2, on the declaration face: the file list has no What I read:
I read the check-runs last. ① Derived judgments
② Semver levelClause-②: yes (widening). The line is right.
③ Boundary flags
FAIL reasonsR3. The request pass writes back the slate labels and the
Observations (non-blocking)
Gates (check-runs on this head, read last, 2026-10-16T19:18Z)41 runs: 36 success, 5 skipped, none still running.
No gate composes a Implemented-by: VERDICT: FAIL |
席位核对 · REWORK (patch round 3) ·
|
…ore writing it A live writer (the boot SLA / dead-run sweep this plugin starts without awaiting, or another node) can write a pending request between the fill's page read and its row write. The request pass now re-reads the row by id, patches only columns still null there, writes the waiting-on only while the row is still pending on the slate the names were read for, and passes back the re-read updated_at — the one-engine-call window the comment-parent-pair backfill accepts. No conditional where. Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
…at replaced it Claude-Session: https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu Co-authored-by: Claude <noreply@anthropic.com>
…proval-display-labels
Addendum: patch round 3 (REWORK
|
| mutation | result |
|---|---|
| item 2 · drop the re-read (the write computes from the page row) | red, (h): the recalled row got pending_approver_labels: "Alice" (expected null) and updated_at rewound to 2026-03-04T05:06:07.000Z (expected the recall's stamp) |
| item 1 · pass the page's stamp instead of the re-read one | red, (h) |
| item 5 · label one row at a time (re-run) | red, (c): received [['u_alice'], ['position:legal'], ['u_mail'], ['position:legal']], expected [['u_alice', 'position:legal', 'u_mail'], ['position:legal']] |
Two readings don't count as written, and I'm stating both:
- The first no-reread leg was void. Its replacement still contained the anchor, and the tool refused it ("the anchor count moved 1 -> 1"). It was re-run with a replacement that does not contain the anchor, and that run is the red above.
- Dropping the slate-equality guard leaves (h) green, by construction. The waiting-on is computed from the re-read slate using the page's batched names. A slot on a slate that moved after the page read was not in that batch, so it labels null and nothing is written. The guard is kept as the REWORK specifies; it is defensive here and has no observable effect.
Tests and gates at fbd7b4d3b
pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2: 81 files / 1103 tests passed. Typecheck exit 0, withcheck:test-typecheckOK and no new debt.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 97 commands at this head. All 97 were run, each exit captured before any pipe: all exit 0, includingcheck:tenant-audit-census(still 229 sites: the re-read is a find, not a write),check:query-options-erasure,check:i18nandcheck:dual-build-cjs-loads.--ran: "97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero).- CI on this head: read the PR's check-runs.
Generated by Claude Code
Contract reviewServed-tier: PR #23984 for card #23889. This is a same-form re-review after patch round 3, on the declaration face. The file list (17 files, +1634 / −103) has no What I read:
I read the check-runs last. ① Derived judgments
② Semver levelClause-②: yes (widening). The line is right.
③ Boundary flags
FAIL reasonsNone. Observations (non-blocking)
Gates (check-runs on this head, read last, 2026-10-16T20:12Z)41 runs: 36 success, 5 skipped, none still running.
No gate composes a Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 · ACCEPT ·
|
Fixes #23889
Clause-②: yes (widening)
What this does
The Approvals Requests and Action History lists are generic list views. They show stored columns and nothing else, so admins read node ids,
flow:api names, slot addresses and raw user ids. The approval engine (ApprovalService) now writes display columns beside the machine ones, and the list views show them. A list never re-derives a label from flow metadata and never resolves an approver address itself. Nothing is removed or renamed.sys_approval_requestprocess_label(the flow's label)process_namesys_approval_requestcurrent_step_label(the Approval node's label)all_requests,my_pending,submitted_by_me, in place ofcurrent_stepsys_approval_requestpending_approver_labels("Waiting On")pending_approvers(open, partial tally, reassign, SLA reassign); cleared with it on every terminal writeall_requests,my_pendingsys_approval_actionstep_labelrecent,all_actions,by_actor, in place ofstep_namesys_approval_actionobject_name+record_id(referenceVia: 'object_name')recent,all_actionssys_approval_actionacted_as_labelacted_asis setrecent,all_actions, in place ofacted_asopenNodeRequest($flowLabelandnode.label, both required by the spec). Soservice-automationneeded no change, andengine.tsis untouched. The labels used to live only innode_config_json(__flowLabel/__nodeLabel), which the inbox door read and a list view cannot. The inbox door'sprocess_label/step_labelnow read the columns first, with the old fallbacks for earlier rows, so what the inbox serves is unchanged.pending_approver_labels,acted_as_label). A slot that names a person (a user id, or an email an account carries) gets the account's name fromresolveUserNames. That is the resolver the approvals door already uses forpending_approver_namesand the timeline's names. Any other slot (position:/team:literals) shows as its stored address, which is what the inbox does today.actionRow(request, action)adds the four copied columns. All 15sys_approval_actioninserts call it and each keeps its ownengine.insert(..., { context: SYSTEM_CTX }), so the tenant-audit census still certifies every write call site. A first cut funnelled them through one writer, and that census correctly went red. Patch round 1's boot backfill adds two SYSTEM-contextengine.updatesites, so the census now certifies 229: regenerated withnode scripts/tenant-audit-census.mjs --write, with the census page's six gate-held figures set to the census's own values.Premise check (the card's measured table, re-read on
origin/main6d1660dc3b)current_step(node id)current_stepprocess_name=flow:NAMEopenNodeRequestwritesflow:plus the flow namepending_approversis a CSV of slot addressesrequest_iddisplaysdisplay_title=process_name · record_idacted_asis a raw slot addressstep_nameis the node idresolveUserNames, and that is the one used. Position and team labels are an open question in the reportcurrent_step_indexis always 0), so the step label is written once, at openRead security: the rule applied
Every new value on a row is either a column of the request that the row's readers already read, or a name the approvals door already serves those same readers for that same request, from the same resolver under the same system read.
sys_approval_requestonly to the callers the approvals door would (request-read-gate.ts, pinned byrequest-read-gate.integration.test.ts), and servessys_approval_actiononly where its request is visible (bindRequestChildReadGates, pinned byrequest-child-read-gate.integration.test.ts). The action's gate is keyed onrequest_idonly, so the newobject_name/record_idcolumns add no new targeting: a read pinned on them is untargeted, which means narrower.enrichRowsalready servespending_approver_names(andlistActionsthe timeline's names) fromresolveUserNamesunderSYSTEM_CTXto exactly those readers. Pin (e) asserts that every label inpending_approver_labelsis a name the approvals door serves the same reader for the same request, or the slot's own address.sys_userfield-level security, these columns must follow. That is noted as an open question.Rows written before this change
A boot-time pass fills them, built over patch rounds 1–3 (REWORKs
6220012954,6221769417and6222977426; addenda6221489557,6222614009and6223588562).backfillApprovalDisplayLabels(src/display-label-backfill.ts) runs onkernel:ready, after the action-slot repair: a keyset walk in pages of 500, onesys_username read per page. A failure is logged atwarn, and the next boot retries.updated_at. Every fill write, and every write of the action-slot repair, runs under{ isSystem: true, preserveAudit: true }and passes the stored stamp back (keptStamp), so the lists that sort onupdated_atkeep their order. A row with no storedupdated_attakes the write's instant: no update can keep it null.plugin-audit's comment-parent-pair backfill. The action passes write from their page read, because no live writer updates an action row.plugin-auditis composed, the first boot writes one system entry in the audit log and one in the record's activity timeline for each row it fills.process_label/current_step_labelcome from the one shared chain,requestDisplayLabels(src/display-labels.ts): the column, else__flowLabel/__nodeLabel, else the humanized machine name. The inbox door, the open write andactionRowall read the same chain, so no door can disagree with another. Pending rows getpending_approver_labelsthroughslateLabels.step_label/object_name/record_idare copied from the parent request, andacted_as_labelcomes throughslotLabels(acted_as).backfillActionSlotsnow writesacted_as_labelbeside everyacted_asit sets.display-label-backfill.integration.test.ts).nullfor a person slot, never its address. It warns once, and the next boot fills it. An empty authored label falls through to the humanized machine name at open.Tests
New
src/approval-list-display-labels.integration.test.ts: the realApprovalServiceon a realObjectQLoverSqlDriver(better-sqlite3, in-memory, real DDL):record_iddeclaringreferenceVia: 'object_name'.At
c90771037(round 0). The later readings are in the addenda: round 1 atb74901ee3(6221489557: 1,100 / 1,100 tests, 97 / 97 derived gates exit 0), round 2 atf8efc941a(6222614009: 1,102 / 1,102) and round 3 atfbd7b4d3b(6223588562: 1,103 / 1,103, 97 / 97 derived gates exit 0):pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2: 80 files / 1094 tests passed.pnpm --filter @objectstack/plugin-approvals typecheck: exit 0 (check:test-typecheckOK, no new debt).node scripts/check-i18n-bundles.mjs --filter=approvals: in sync. The bundles were regenerated with--write, and zh-CN / ja-JP / es-ES values were written for the seven new keys.Ablations (
scripts/ablation-replace.mjs, each restore proven blob == HEAD55ada4ce937awithgit diff HEADempty; green control 6/6 first):slateLabelsreturns nullactionRowdropsobject_nameactionRowdropsstep_labelcurrent_step_labelacted_as_label= rawacted_asA first pass of these five came back red because
packages/spec/distwent missing mid-run while a concurrent gate rebuilt it (each run said "no tests"). Those readings were discarded and the whole set was re-run on a stable tree.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatc90771037derives 67 commands. All were run, each exit captured before any pipe, and all 67 exit 0.--ranreconciliation: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero: every line carries its exit code). Two prerequisites were cleared before that run: the shallow clone was deepened to the commitcheck-plugin-teardown-shape --self-test's fixture pins, and the dist treescheck:dual-build-cjs-loadsreads were built. CI families are read from this PR's check-runs.Acceptance notes
highlightFieldsandtitleFormat/display_titleon both objects are unchanged. The request's record title still readsflow:NAME · RECORD_IDwherever it is drawn, for example in the action list'srequest_idcell. Switching it toprocess_labelneeds a null guard for earlier rows, and it touchessys-approval-display-title.test.ts's pairing, so it is left for the console half (Approvals: Requests and Action History show record names, step labels, submitter and the current approver objectui#12608) to ask for.sys-approval-request.page.ts(PR feat(plugin-approvals): the request page's Details tab ends with the request snapshot's raw JSON, gated on studio.access #23837) andpackages/services/service-automation/src/engine.ts(PR feat(spec)!: reserve the retired callout node types http_request, http_call and webhook; flow-node-http-callout-rename leaves the load path (F5-http of #23543) #23954) are untouched, as claimed. Nopackages/specchange:ApprovalRequestRow/ApprovalActionRowdo not gain the new columns. They are served on the generic data door, which is what the lists read.🤖 Generated with Claude Code
https://claude.ai/code/session_01LZZjeuwVdnwn6dFDPLwTBu
Seat edit ·
domain:servicesseat 2 ·session_018VqJPGA1NDNSCZsnecsChP· 2026-10-16T17:55Z: "Rows written before this change", the census figure and the readings pointer brought to patch round 1 (addendum6221489557).Seat edit ·
domain:servicesseat 2 ·session_018VqJPGA1NDNSCZsnecsChP· 2026-10-16T20:13Z: "Rows written before this change" and the readings pointer brought to patch round 3 (addendum6223588562, contract record6223828142PASS).Generated by Claude Code