Repository navigation
fix(approvals): every sys_user lookup approvals writes holds an id or null — machine actors record none, notify forwards only a person, reassign parties are slot addresses (ADR-0118 D1) - #21514
Conversation
… slot addresses (wip) The SLA and dead-run sweeps write actor_id null, notify forwards only the person a context vouches for, reassign_from / reassign_to become slot-address text columns, and the boot-time backfill nulls stored machine sentinels. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ved to null Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
… slot addresses; translations and changeset Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…r0118-actor-family
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
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 84c31c23c48cdaf1067e177e4c6177245362ab7f && git checkout 84c31c23c48cdaf1067e177e4c6177245362ab7f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f9a8eb889ead684cb3f35f4ee013e634513ad1af e72a7d21767e3f154947ee1503aaa6fe0b72218d && git checkout -B drift-repro f9a8eb889ead684cb3f35f4ee013e634513ad1af && git merge --no-ff e72a7d21767e3f154947ee1503aaa6fe0b72218d
node scripts/docs-audit/affected-docs.mjs --json f9a8eb889ead684cb3f35f4ee013e634513ad1af
|
…erseded in part by ADR-0118 D1; SLA checklist expects no actor (objectstack-ai#21556) Fixes objectstack-ai#21517 Clause-②: no Three texts still described the approval actor as a sentinel or as a user after the actor-column change landed (`88fb5e85a0`). This PR corrects all three, as triage ruled on the card (comment `5964851617`): one card, one PR, three text positions. Text only: no schema, export, type or runtime change. ## What changes 1. **`ApprovalActionRow` reassign TSDoc** (`packages/spec/src/contracts/approval-service.ts`). - Before: `reassign_from` / `reassign_to` were "the user whose pending-approver slot was moved, and the user who received it", and each `*_name` was "`sys_user.name`, when resolvable". - After: both hold a slot address in its stored spelling: a user id, an email, or a position address (`position:NAME`; any other `type:value` literal a slate kept is stored the same way). Like `acted_as`, neither is a `sys_user` reference, and neither makes a claim about who made the move: that person is `actor_id`. A `*_name` resolves only when the address names an account: a user id, or an email an account carries. A position address never resolves, so an absent name means "render the address". - The type (`string`) does not change. The rewritten block cites no tracker number; the old block's tracker citation went with the rewrite. 2. **ADR-0042 status line** (`docs/adr/0042-approval-sla-escalation.md`). One continuation line under **Status**, in the form ADR-0046 uses for a decision superseded in part: > · **Superseded in part (2026-08-02, ADR-0118 D1)** — §2's reserved actor `system:sla`, wherever this record names it: machine actions record `actor_id` null, and the `escalate` row is the attribution. ADR-0118 is linked in the file. The decision text is not rewritten: the record keeps what was decided on 2026-06-12, and the status line points to what supersedes it (Prime Directive 13). "Wherever this record names it" covers the TL;DR, Mechanics and Consequences mentions as well as §2 itself. 3. **Platform checklist `approvals.sla-escalation`** (`docs/qa/platform-checklist/areas/approvals.json`), revision 1 to 2 with a history entry. - Before: step 5 and the single-shot clause's verify text expected the `escalate` row's actor to be `SLA_ACTOR_ID`. - After: both expect no actor. `actor_id` is absent on the `GET /:id/actions` read and stored null, because a machine action records no actor and the `escalate` row is the attribution (ADR-0118 D1). A QA run that followed the old text would report a false failure. 4. **Changeset**: `@objectstack/spec` `patch`, because the TSDoc ships in the published `.d.ts`. ## The texts were checked against the runtime (read at `24dc7c1134`) - **Reassign write.** `reassign()` in `packages/plugins/plugin-approvals/src/approval-service.ts` records `reassign_from: from, reassign_to: to`, the two slot addresses as the slate spells them. `sys-approval-action.object.ts` declares both as `Field.text`, described as "in the slot's stored spelling: a user id, an email, or a position address". - **Names.** `listActions()` sends only addresses without a `:` to `resolveUserNames`. That function looks each one up by `sys_user.id`, then looks up the ones still unresolved that contain an `@` by `sys_user.email`. - **Escalate row.** `escalateRequest()` inserts `actor_id: null`. `rowFromAction` maps null to undefined, and the REST route answers `res.json({ data: rows })`, so the key is absent on the read. - **New TSDoc in the build.** After `pnpm --filter @objectstack/spec build`, the new sentence is in `packages/spec/dist/contracts/index.d.ts` and `index.d.mts`, and the old "pending-approver slot was moved" sentence is in no `dist` file. ## Gates The union was re-run on HEAD `bfc5e472c2`, the final commit. That commit is a merge of `origin/main` `fd5a1cd597`, which touched only `driver-turso` and a changeset, nothing on this PR's paths. - `pnpm --filter @objectstack/spec build`: exit 0. `check-dts-emitted: @objectstack/spec - 38/38 declared declaration file(s) present.` - `pnpm --filter @objectstack/spec check:generated`: exit 0. `✓ All 15 generated artifacts are up to date` - `pnpm --filter @objectstack/spec typecheck`: exit 0. `check:test-typecheck: OK` - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: exit 0. `Test Files 604 passed (604)`, `Tests 17861 passed | 1 todo` - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 86 commands. All 86 were run on `bfc5e472c2` with their exit codes recorded, then reconciled: `✓ dispatch-gates --ran: 86 derived famil(ies) accounted for — 85 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).` Among them: - `pnpm check:doc-authoring`: `✓ doc authoring guard: 17314 customer-facing string(s) across 1249 spec sources clean — no internal issue-id references` - `pnpm check:platform-checklist`: `check-platform-checklist: OK — 15 areas, 269 items (265 active, 2 planned)` - `pnpm check:nul-bytes`: `check-nul-bytes: OK (scanned 9898 text file(s) ... no raw ASCII control bytes).` - `check-empty-changeset`: `✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).` - `check-adr-0087-registration`: `✓ ... this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).` - `check-changeset-no-major`: ``✓ This diff introduces no `major` bump.`` The level axis was also run offline with this body as the `--event` payload (see the report on the card). - **NOT MEASURED: `pnpm check:dual-build-cjs-loads`.** Reason: `PREREQUISITE NOT MET`. The gate reads the built output of all 83 workspace packages, which takes a full `pnpm build`. This PR edits one comment block in `packages/spec`, and CI's Build Core runs the gate. - Roster gates whose roster sits in a directory this diff touches were run too, all exit 0: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`. `check:meta-url-spelling` and `check:spec-changes` ran inside `check:generated`. - `node scripts/pm/check-governed-merges.mjs --branch claude/issue-21517-approval-actor-texts`: `⛔ GOVERNED`, Tier H, by `docs/adr/0042-approval-sla-escalation.md`. Size is 45 changed lines. ## Acceptance notes - **Governed, Tier H.** The ADR line makes this PR governed, and triage accepted that. It stays a draft. It lands only on an authorized approval or a human merge. - **Only the status line, no section note.** ADR-0046 also puts a note at the superseded section. The ruling names the status line, so only that line is added. A reader who lands on §2 directly sees no marker there. Adding one is a follow-up for the ADR's owner if wanted. - **Reassign edge, read in code and not measured.** In `reassign()`, `from` is `input.from ?? slot ?? actorId`. On the admin-rescue branch (the caller holds no slot, and `from` is not on the slate), the whole slate is replaced with the one address `to`. The audit row then records `reassign_from` as the caller-named `from` or the admin's own user id, not a slot that was handed over. The new TSDoc states the contract ("the pending-approver slot that was handed over"), which matches the object's field description. This branch was not driven through a public door, so it is noted here and not filed. - **Not here: the objectui timeline's "System (SLA)" rendering.** Triage ruled it stays PR objectstack-ai#21514's acceptance note. - **Release:** a `patch` changeset (`Clause-②: no`). The TSDoc ships in the published `.d.ts`, and every changeset gate run passed. ## 维护者速读(草稿) **改了什么**:三处文字,全部是说明性文本,不改任何类型、接口或运行时行为。① 审批动作记录的 TSDoc 现在写明:「转办来源 / 转办目标」两个字段存的是审批槽位的地址(用户 id、邮箱或岗位地址),不一定是某个人,显示名只在地址对应到一个账号时才解析得出。② ADR-0042 在状态行加一句:第 2 节「SLA 机器决策记为保留身份 `system:sla`」已被 ADR-0118 D1 部分取代,机器动作的行为人记为空,由 `escalate` 那一行本身说明是 SLA 触发的。③ QA 测试清单里的 SLA 升级用例改为期望 `escalate` 行没有行为人。 **为什么改**:运行时早已按 ADR-0118 实现(转办字段存槽位地址,SLA 扫描记空行为人),但这三处文字还是旧说法。AI 或开发者按旧文字写代码、跑 QA,会把岗位地址当成用户去关联,或把正确行为误报成失败。ADR 被推翻却没有状态行,也违反「已接受的 ADR 在被新 ADR 取代前一直有效」的原则。 **风险与代价(含回滚)**:风险很低:只改文字,发布的 `.d.ts` 只有注释变化,附 patch 级 changeset。因为碰了 `docs/adr/**`,本 PR 属于受管面(Tier H),需要您批准才能合入。回滚就是 revert 这个提交,没有数据或迁移影响。 **席位意见**: **你要做的**:看一眼 ADR-0042 新加的那一行状态说明,措辞没问题就批准;如果还希望在第 2 节标题下也加一条指向说明,请在 PR 上说一声。 --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21455
Clause-②: no
What changes
Under ADR-0118 D1 a lookup to
sys_userholds a user id or null, never a placeholder. Four writers in the approvals plugin broke that, and a lookup holding a non-id drops the row from every join and report on it, silently. This applies triage's position-by-position ruling (5960380029). It builds on #21411 (PR #21493,6f17d1d36): the samerecordedActor, the same slot-address column kind (acted_as), and the same boot-time repair.SLA and dead-run sweeps:
actor_idis null.escalateRequestwrites theescalaterow withactor_id: null. Before, it wrotesystem:sla.auto_approve/auto_rejectdecision runs underSYSTEM_CTX. That context vouches for nobody, sorecordedActorrecords null. The special case that keptsystem:slais removed.abandonForDeadRunwrites itsrecallrow withactor_id: null. Before, it wrotesystem:dead-run.escalaterow's comment names the configured action, and the dead-runrecallnames the run and its status.DEAD_RUN_ACTOR_IDis deleted.SLA_ACTOR_IDstays, as the sweep's acting identity only:decide()under a system context requires an explicit actor. Its docblock says it is never recorded.reassign_from/reassign_toare slot-address fields. They are nowField.text({ maxLength: 255 }), the declared form ofacted_as, instead ofField.lookup('sys_user'). The descriptions say they hold the slot's address in its stored spelling: a user id, an email or a position address. Translations are regenerated withnode scripts/check-i18n-bundles.mjs --write, and the zh-CN, ja-JP and es-ES help texts are written by hand. The zh-CN labels also become neutral (from party / to party instead of from person / to person).notifyforwards a person or nothing.notifyno longer takes an actor string. A caller hands inactorContext(the action'sExecutionContext), andnotifyforwardsrecordedActor(actorContext)asactorId, or omits it. So no string path exists for a slot literal or a sentinel.sendBack(both notifications),reassign,remind(both),requestInfoandcomment.service-messagingis unchanged. It writessys_notification.actor_idfromEmitInput.actorIdas given, so the producer is where this is fixed.Stored sentinels.
backfillActionSlotspass 1 already scannedactor_idfor values that contain:or@. The$ninexclusion of the two sentinels is gone, and each row takes one of two arms with the same singleupdatecall:acted_as, as before;acted_as, because a sweep took no slot.The result gains
sentinelsCleared, and the plugin's boot log reports it. The pass stays idempotent: a second run writes{0, 0, 0}. The sentinels are now spelled only inLEGACY_MACHINE_SENTINELS(renamed fromRESERVED_MACHINE_ACTORS). The update call-site count is unchanged, so the tenant-audit census needs no regeneration.Docs. In
content/docs/automation/approvals.mdx, the dead-run sentence that documentedsystem:dead-runnow says the row records no actor and names the run. Two short sentences say the escalation rows and notifications record no actor, and thatreassign_from/reassign_tohold slot addresses.Readers of the sentinels (measured by grep at
e72a7d2176)system:slaorsystem:dead-run.action: 'escalate'(the ADR-0042 §1 marker), not the actor.status: 'pending'requests, so it does not key on the actor either.listActionsresolves names for ids and skips:values.CHANGELOG.mdfiles, ADR-0042, the QA checklist JSON, and the backfill's legacy list..objectui-sha(89cad75d5):apps/console/src/pages/system/ApprovalsInboxPage.tsx:2331rendersactor_id === 'system:sla'as "System (SLA)". Other actors go throughformatIdentity, which shows an em dash for an empty actor. See the acceptance notes.What the field-type change does to a stored column (measured)
The old declaration created the table and stored three reassign rows (a position, an email, a user id). The new declaration then booted over the same database. A positive control confirmed the DDL capture: it saw the
create tableon the first boot.varchar(255)character varying(255)varchar(255)character varying(255)detectManagedDriftforreassign_from/reassign_toacted_astoday)TEXTtextThe lookup arm never emitted a foreign key (
sql-driver.ts, the #11567 note), so nothing has to be dropped. Neither column is in a list view,highlightFieldsor an index. InlistActions,reassign_from_name/reassign_to_nameresolve where an address names an account: a user id, or an email an account carries (the existingresolveUserNames). A position has no name. The family pin asserts all three cases.Pins
actor-lookups-hold-ids.integration.test.ts(new). This is the family pin, on a realObjectQLoverSqlDriver(better-sqlite3 in memory, real DDL).sys_userlookups from the plugin's own manifestobjects, not from a re-listed set.ApprovalService: position-slot and email-slot actions, comment, request-info, remind, three reassign shapes, a vote, the action link, send-back within budget and past it, recall, an out-of-office substitution, all four SLA actions, and the dead-run sweep.sys_userid or null;notifyactor is a person, and each person-driven topic forwards the holder, never the address;action-slot-backfill.integration.test.tsnow plants three sentinel rows, one of them anapproveat step 0 on a pending request (the shape pass 2 stamps). It asserts{ literalsMoved: 3, sentinelsCleared: 3, votesStamped: 1 }, then 0 sentinels left in the lookup, user-id rows untouched, and a second run writing{0, 0, 0}.action-slot-backfill-wiring.test.ts: a run that only cleared sentinels still logs.The existing pins that asserted the sentinels now assert null:
approval-service.test.ts: SLA notify, auto_approve, the system-context case, and dead-run;approval-actor-impersonation.test.ts;run-status-liveness.test.ts;approval-service-sla-calendar-clock.test.ts.The
approver-address-readers.test.tsledger drops the$ninsite.Ablations (each through
scripts/ablation-replace.mjs, wrapped, direction predicted first; every restore proven: blob equals HEAD andgit diff HEADempty)escalaterow writesSLA_ACTOR_IDagainnotifyforwardsSLA_ACTOR_IDwhen no context vouchesreassign_fromback toField.lookup('sys_user')position:legaland the emailsentinel = false)sentinelsCleared0, not 3)sys_userlookup that nothing writesThe first attempt at leg E was a no-op: the tool refused it because the replacement contained the anchor (count 1 to 1), and it restored the file. I re-ran it with a semantically identical re-spelling.
Gates, at
e72a7d2176(after mergingorigin/main85e29b885)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 95 commands, and all 95 exit 0.check:skill-examples,check:dual-build-cjs-loads). Afterturbo run build --filter=!@objectstack/docs, both exit 0.--ranwith exit codes: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN.check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity.pnpm --filter @objectstack/plugin-approvals typecheck: exit 0 (check:test-typecheckOK).pnpm --filter @objectstack/plugin-approvals test: 59 files, 875 tests passed.pnpm --filter @objectstack/service-messaging test: 46 files, 507 tests passed.packages/qa/dogfood/test/position-address-readers.dogfood.test.ts: 1 of 1 passed, against a rebuiltplugin-approvalsdist..tsfiles:--no-inline-config --format jsonreports 16 files, 0 errors and 0 warnings.eslint.config.mjs'spackages/**/*.{ts,…}globs.parserOptions.project), so this diff cannot move the verdict on an untouched file.Changeset
@objectstack/plugin-approvalspatch,Clause-②: no. It says it supersedes the "The SLA sweep keeps its reservedsystem:slaactor for now" sentence of the unreleased.changeset/21411-approval-actor-person.md, which this PR does not edit. Both ship in one release.Acceptance notes
system:sla"System (SLA)" and otherwise shows an empty actor as an em dash. After this PR, the SLA and dead-run rows have no actor, so they show the em dash, as out-of-office, cancel and backfilled rows already do. ADR-0118 D1 makes "System" a render rule for an empty actor, and D5 answers "which automation" from association fields: for an SLA auto-decision that is theescalaterow right before it, whose comment names the action. The renderer change is objectui work, the same carrier as approvals:sys_approval_action.actor_id(asys_userlookup) records the slot literal (position:<p>, or an email) instead of the deciding user, so the person who decided is on no column (ADR-0118 D1) #21411'sacted_asrendering. No reader needs a column the row kind does not carry, so none is added.system:sla", and clients rendering it) is the decision ADR-0118 D1 supersedes for this column. ADR-0042 has no amended status line, anddocs/adr/**is outside this PR's surface (Tier H). This needs a seat or maintainer decision.ApprovalActionRow.reassign_from/reassign_toTSDoc inpackages/spec/src/contracts/approval-service.tsstill says "the user whose pending-approver slot was moved", and*_namesayssys_user.name. The type (string) is unchanged. The text needs a spec-lane correction, and this PR makes nopackages/specedit.docs/qa/platform-checklist/areas/approvals.json(the SLA single-shot item) says theescalaterow's actor isSLA_ACTOR_ID. The row now records no actor. The item needs a one-word change, outside this PR's surface.actor_idforsystem:sla/system:dead-runreads the row kind instead, and code that expandedreassign_from/reassign_toassys_userreads them as addresses. The changeset says both.Generated by Claude Code