Skip to content

fix(approvals): actor_id records the person who decided, acted_as the slot it was taken as; stored slot literals move out at boot - #21493

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21411-approval-actor-person
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21411-approval-actor-person

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21411
Clause-②: no

What changes

sys_approval_action.actor_id is a lookup to sys_user. Under ADR-0118 D1 it holds a user id or nothing. A slot-gated action recorded the SLOT it took there instead: the literal position:P for a position staffed after the request opened, or an email for a user approver authored as one.

Measured on a booted showcase at main 53fd35e3e: a position-slot approve wrote 8 rows across both databases, and none of them names the person who decided. The 3 audit-ledger rows carry user_id / actor null, the 3 activity rows carry actor_id null, and the action row carries actor_id position:finance. That is the raise to p1.

Triage ruling B (5954022700), with its retriage answers (5960329631), puts the person and the slot in two columns.

  • Column. sys_approval_action.acted_as (text, max 255) holds the pending-approver slot the action was taken as, in its stored spelling. actor_id gains its ADR-0118 D1 describe. Both are in highlightFields and the recent / all_actions grids, as via_override was. Translations are regenerated, with zh-CN, ja-JP and es-ES written by hand.
  • Writes.
    • Every insert in decideNode, sendBack (revise and auto-reject), reassign, requestInfo, comment, recall, resubmit and remind records actor_id: recordedActor(actorId, context). That is the person the context vouches for, never an address the caller named. The slot-gated ones also record acted_as: slot ?? null.
    • A system context that vouches for nobody records nobody. One exception, kept by name: the SLA sweep's reserved system:sla, which is approvals: ADR-0118 D1 family — sys_user actor columns still hold sentinels and slot literals (system:sla / system:dead-run, reassign_from / reassign_to, sys_notification.actor_id) #21455's.
    • The action link (redeemActionToken) now puts the person behind the token's slot on the context: a user id as itself, an email as the ONE account carrying it, otherwise nobody. Before, it put the slot itself there, so an email-bound link recorded the email as the acting user, on the action row and in the status mirror alike.
  • Readers.
  • Boot-time backfill (action-slot-backfill.ts). It is hooked on kernel:ready beside backfillApproverIndex, the plugin's existing boot-time repair, so it ships as code and no agent runs a bulk write.
    • Pass 1 covers the whole history. A row whose actor_id holds a slot address (contains : or @, and is not one of the reserved system:sla / system:dead-run) gets acted_as := actor_id and actor_id := null. No stored record names its decider, so null is ADR-0118's value, not a guess.
    • Pass 2 covers the approve votes, at step 0, of still-pending requests whose acted_as is empty: they get acted_as := actor_id. This is exact, not a guess, because an override finalizes the node.
    • No other row is stamped. Finished user-id rows are read by their person.
    • It is idempotent (both predicates are empty after a run). A failure logs at error with the consequence and the fix.
  • Docs. content/docs/automation/approvals.mdx: the sentence that said the decision is recorded in actor_id under its slot now names both columns.

Pins

Ablations (committed first; every leg through scripts/ablation-replace.mjs, with the anchor hit once, the blob changed, the restore proven equal to HEAD and git diff HEAD empty)

Legs a to f ran at b668cf134; leg g ran at dc6d72a9c. Each direction was predicted before its run.

leg mutation predicted observed
a decideNode writes no acted_as red, more than the slot pins (the tally too) 24 failed / 304 passed: every slot pin, plus the unanimous, quorum and per_group tallies and the probe. ⚠ The first attempt was a no-op: the tool refused it because the replacement text was a substring of the anchor (count 1 to 1), and it restored. Re-run with a distinct replacement.
b tally reads a.actor_id red: tally pins, backfill end to end, enumeration pin 14 failed / 320 passed across all 3 files
b2 decision_progress reads a.actor_id red: per_group progress pin and enumeration pin 4 failed / 328 passed
c backfill pass 2 writes nothing red: backfill pin 1 failed / 1 passed
d backfill pass 1 writes nothing red: backfill pin 1 failed / 1 passed
e the kernel:ready hook is removed red: wiring pin 2 failed
f the probe's { actor_id: uid } half is removed red: person pins and enumeration pin 3 failed / 329 passed
g listActions stops mapping acted_as red: action-log pin only 1 failed / 328 passed

Gates, at dc6d72a9c

  • dispatch-gates --repo objectstack-ai/objectstack --commands derived 97 commands. All 97 ran with exit 0, plus the 4 in-path roster gates it flagged (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity), also exit 0. --ran with exit codes answers: 97 derived, 97 run, 0 NOT-MEASURED (a derived zero).
  • pnpm --filter @objectstack/plugin-approvals typecheck: exit 0 (check:test-typecheck OK). pnpm --filter @objectstack/plugin-approvals test: 58 files, 868 tests passed.
  • The dogfood file: 1 of 1 passed. pnpm --filter @objectstack/dogfood typecheck: exit 0.
  • Earlier reds, each repaired in this PR:
    • check:engine-double-contract: the wiring test's double now declares no write verbs.
    • check-tenant-audit-census: the backfill adds 2 elevated update sites (229 to 231 write call sites, 110 to 112 elevated). node scripts/tenant-audit-census.mjs --write regenerated both census tables, and the page's hand-written prose counts were updated to match. This adds content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md to the diff, declared here.

Changeset

@objectstack/plugin-approvals patch, Clause-②: no (the spec widening is #21458's). It states that it supersedes the "What is recorded" sentence of the unreleased .changeset/21379-position-address-readers.md, which this PR does not edit. Both ship in one release.

Acceptance notes

  • One widening, by ruling (Q3 = A): a person who decided under a position they later lost keeps sight of that request. The old probe hid it.
  • Behaviour narrowed for system contexts: a machine caller that names an actor without vouching for a person on the context now records actor_id null, with the slot still taken and recorded. The only first-party machine callers are the SLA sweep (kept by name) and the action link (which now vouches for the resolved person). Test fixtures that decided from a bare system context and asserted actor_id moved to a vouching context.
  • Out of this PR, the ADR-0118 D1 family on approvals: ADR-0118 D1 family — sys_user actor columns still hold sentinels and slot literals (system:sla / system:dead-run, reassign_from / reassign_to, sys_notification.actor_id) #21455: the SLA and dead-run sentinels in actor_id, reassign_from / reassign_to holding slot literals and emails (reassign_from still records the slot handed over; a pin here names it), and sys_notification.actor_id fed by notify.
  • Console: rendering acted_as beside the actor's name in the timeline is objectui work. Until it lands, a backfilled historical row shows no actor, and the slot is on the wire.

Generated by Claude Code

claude added 7 commits October 2, 2026 22:43
…n acted_as

sys_approval_action.actor_id is a sys_user lookup, but a slot-gated action
recorded the SLOT it took there: a position literal for a position staffed after
open, an email for an email-authored user approver. On those decisions no row
the decision writes names the person who decided.

- sys_approval_action gains acted_as (text, the slot in its stored spelling);
  actor_id records the person the context vouches for (recordedActor), with the
  SLA sweep's reserved sentinel kept by name (separate ADR-0118 D1 debt).
- The multi-approver tally and decision_progress read acted_as; the
  already-acted probe matches actor_id against the caller's user id OR
  acted_as against the caller's acting addresses - two facts, no fallback.
- The action link resolves an email-bound token to the one account carrying
  that email, so its decision records that person (or none).
- action-slot-backfill.ts, run on kernel:ready beside the approver-index
  rebuild: moves stored slot literals out of actor_id into acted_as
  (actor_id becomes null), and stamps acted_as on the approve votes a pending
  request's tally still counts. Idempotent.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
- approval-service.test.ts: the #21379 slot tests now assert both columns; a
  #21411 block pins position / email / user-id slots through the session door
  and the action link (an email-bound token records the one account carrying
  it, or nobody), an override recording the admin and no slot, a system
  context recording nobody (the SLA sentinel kept), per_group tally and
  decision_progress counting acted_as, and the already-acted probe's two
  halves (person, slot) with a legacy row found by its person and a literal
  left in actor_id never read as a slot.
- approver-address-readers.test.ts: acted_as replaces actor_id as a slot
  column; every read of actor_id is classified, and the only comparison is
  with the caller's user id.
- action-slot-backfill.integration.test.ts: real ObjectQL + SQL driver;
  literals moved, pending votes stamped, finished user-id rows and sentinels
  untouched, a second run writes nothing, and an in-flight unanimous request
  still finalizes on the votes the old writer recorded.
- dogfood position-address-readers pin: recorded() reads actor_id, acted_as,
  via_override and asserts person plus slot.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…slot in acted_as; changeset

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…nd its error-level failure log

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…'s two writes; the wiring test's double declares no write verbs

The action-slot backfill adds two elevated `update` sites on
sys_approval_action (229 -> 231 write call sites, 110 -> 112 decidably
elevated); `node scripts/tenant-audit-census.mjs --write` regenerated both
census tables and the page's hand-written prose counts follow them.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
#21458's ApprovalActionRow.acted_as)

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
listActions maps sys_approval_action.acted_as onto ApprovalActionRow.acted_as
(declared by @objectstack/spec in #21458), so a timeline shows who acted and as
which slot; a row no slot admitted omits it. Pinned with a backfilled row that
shows its slot and no person.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 39 documentable anchor(s).

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 100c394f6fd0113377bccdabed6ecf3b04b720da.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 36 pages), sys_user (literal, 37 pages)
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 100c394f6fd0113377bccdabed6ecf3b04b720da → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0666a388f7a4731b50ffc3acae825ee9d75d3ac2 — the merge of head dc6d72a9cdf3d1bdf2bc5f0321b0ad3b0448a26a into base 100c394f6fd0113377bccdabed6ecf3b04b720da, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0666a388f7a4731b50ffc3acae825ee9d75d3ac2 && git checkout 0666a388f7a4731b50ffc3acae825ee9d75d3ac2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 100c394f6fd0113377bccdabed6ecf3b04b720da dc6d72a9cdf3d1bdf2bc5f0321b0ad3b0448a26a && git checkout -B drift-repro 100c394f6fd0113377bccdabed6ecf3b04b720da && git merge --no-ff dc6d72a9cdf3d1bdf2bc5f0321b0ad3b0448a26a

node scripts/docs-audit/affected-docs.mjs --json 100c394f6fd0113377bccdabed6ecf3b04b720da

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 100c394f6fd0113377bccdabed6ecf3b04b720da → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 00:06
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 00:07
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 6f17d1d Oct 3, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21411-approval-actor-person branch October 3, 2026 00:37
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… null — machine actors record none, notify forwards only a person, reassign parties are slot addresses (ADR-0118 D1) (objectstack-ai#21514)

Fixes objectstack-ai#21455
Clause-②: no

## What changes

Under ADR-0118 D1 a lookup to `sys_user` holds 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 objectstack-ai#21411 (PR objectstack-ai#21493, `6f17d1d36`): the same
`recordedActor`, the same slot-address column kind (`acted_as`), and the
same boot-time repair.

1. **SLA and dead-run sweeps: `actor_id` is null.**
- `escalateRequest` writes the `escalate` row with `actor_id: null`.
Before, it wrote `system:sla`.
- The `auto_approve` / `auto_reject` decision runs under `SYSTEM_CTX`.
That context vouches for nobody, so `recordedActor` records null. The
special case that kept `system:sla` is removed.
- `abandonForDeadRun` writes its `recall` row with `actor_id: null`.
Before, it wrote `system:dead-run`.
- The fact that a sweep acted is the row's own kind. The `escalate`
row's comment names the configured action, and the dead-run `recall`
names the run and its status. `DEAD_RUN_ACTOR_ID` is deleted.
- `SLA_ACTOR_ID` stays, as the sweep's acting identity only: `decide()`
under a system context requires an explicit actor. Its docblock says it
is never recorded.
2. **`reassign_from` / `reassign_to` are slot-address fields.** They are
now `Field.text({ maxLength: 255 })`, the declared form of `acted_as`,
instead of `Field.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 with `node
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).
3. **`notify` forwards a person or nothing.** `notify` no longer takes
an actor string. A caller hands in `actorContext` (the action's
`ExecutionContext`), and `notify` forwards `recordedActor(actorContext)`
as `actorId`, or omits it. So no string path exists for a slot literal
or a sentinel.
- Callers that pass the context: `sendBack` (both notifications),
`reassign`, `remind` (both), `requestInfo` and `comment`.
- Callers that pass nothing: the three SLA notifications, and the two
out-of-office notifications, which already passed nothing.
- `service-messaging` is unchanged. It writes
`sys_notification.actor_id` from `EmitInput.actorId` as given, so the
producer is where this is fixed.
4. **Stored sentinels.** `backfillActionSlots` pass 1 already scanned
`actor_id` for values that contain `:` or `@`. The `$nin` exclusion of
the two sentinels is gone, and each row takes one of two arms with the
same single `update` call:
   - a slot literal moves to `acted_as`, as before;
- a sentinel is nulled, and gets no `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 in `LEGACY_MACHINE_SENTINELS` (renamed
from `RESERVED_MACHINE_ACTORS`). The update call-site count is
unchanged, so the tenant-audit census needs no regeneration.
5. **Docs.** In `content/docs/automation/approvals.mdx`, the dead-run
sentence that documented `system:dead-run` now says the row records no
actor and names the run. Two short sentences say the escalation rows and
notifications record no actor, and that `reassign_from` / `reassign_to`
hold slot addresses.

## Readers of the sentinels (measured by grep at `e72a7d2176`)

- **This repo:** no runtime reader keys on `system:sla` or
`system:dead-run`.
- The SLA re-run guard keys on `action: 'escalate'` (the ADR-0042 §1
marker), not the actor.
- The dead-run sweep re-scans only `status: 'pending'` requests, so it
does not key on the actor either.
  - `listActions` resolves names for ids and skips `:` values.
  - No REST approvals route and no analytics code reads either value.
- The remaining hits: tests (updated here), release-owned `CHANGELOG.md`
files, ADR-0042, the QA checklist JSON, and the backfill's legacy list.
- **objectui at the pinned `.objectui-sha` (`89cad75d5`):**
`apps/console/src/pages/system/ApprovalsInboxPage.tsx:2331` renders
`actor_id === 'system:sla'` as "System (SLA)". Other actors go through
`formatIdentity`, 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 table` on the first boot.

| | SQLite (better-sqlite3) | PostgreSQL 16.14 |
| --- | --- | --- |
| column before | `varchar(255)` | `character varying(255)` |
| DDL on the upgrade boot | none | none |
| column after | `varchar(255)` | `character varying(255)` |
| stored values | all 3 unchanged | all 3 unchanged |
| foreign keys / indexes on the columns | 0 / 0, before and after | 0 /
0, before and after |
| `detectManagedDrift` for `reassign_from` / `reassign_to` | none | none
|
| a fresh column (as `acted_as` today) | `TEXT` | `text` |

The lookup arm never emitted a foreign key (`sql-driver.ts`, the objectstack-ai#11567
note), so nothing has to be dropped. Neither column is in a list view,
`highlightFields` or an index. In `listActions`, `reassign_from_name` /
`reassign_to_name` resolve where an address names an account: a user id,
or an email an account carries (the existing `resolveUserNames`). 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 real `ObjectQL` over `SqlDriver` (better-sqlite3 in
memory, real DDL).
- It reads the `sys_user` lookups from the plugin's own manifest
`objects`, not from a re-listed set.
- It drives every write path through the real `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.
  - It then asserts:
- every stored value of every declared lookup is a `sys_user` id or
null;
- every declared lookup was written at least once, so a new lookup stays
red until the battery writes it;
- every `notify` actor is a person, and each person-driven topic
forwards the holder, never the address;
- each machine actor writes null, and the machine topics name no actor;
- a position and an email round-trip in the slot fields, with the names
resolving as described above.
- **`action-slot-backfill.integration.test.ts`** now plants three
sentinel rows, one of them an `approve` at 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.ts` ledger drops the `$nin` site.

## Ablations (each through `scripts/ablation-replace.mjs`, wrapped,
direction predicted first; every restore proven: blob equals HEAD and
`git diff HEAD` empty)

| leg | mutation | predicted | observed |
| --- | --- | --- | --- |
| A | the `escalate` row writes `SLA_ACTOR_ID` again | red: id-or-null
and machine pins, the SLA tests, the SLA-clock test; round-trip green |
5 failed / 335 passed, those five exactly |
| B | `notify` forwards `SLA_ACTOR_ID` when no context vouches | red:
notify pin, machine pin, SLA notify test; stored-row pin green | 3
failed / 337 passed, those three exactly |
| C | `reassign_from` back to `Field.lookup('sys_user')` | red:
declared-kind and id-or-null pins; round-trip and coverage green | 2
failed / 4 passed; the offenders are named: `position:legal` and the
email |
| D | the backfill's sentinel arm removed (`sentinel = false`) | red:
the backfill pin | 1 failed / 4 passed (`sentinelsCleared` 0, not 3) |
| E | a new `sys_user` lookup that nothing writes | red: the coverage
pin only | 1 failed / 5 passed; it names the new field |

The 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 merging `origin/main` `85e29b885`)

- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 95 commands, and all 95 exit 0.
- Two of them first exited 3 (PREREQUISITE NOT MET:
`check:skill-examples`, `check:dual-build-cjs-loads`). After `turbo run
build --filter=!@objectstack/docs`, both exit 0.
- `--ran` with exit codes: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN.
- The 4 in-path roster gates also exit 0: `check-changeset-fixed`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`.
- `pnpm --filter @objectstack/plugin-approvals typecheck`: exit 0
(`check:test-typecheck` OK).
- `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 rebuilt `plugin-approvals` dist.
- eslint is narrowed to the 16 changed `.ts` files: `--no-inline-config
--format json` reports 16 files, 0 errors and 0 warnings.
- The population comes from `eslint.config.mjs`'s `packages/**/*.{ts,…}`
globs.
- The config never enables type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict on an
untouched file.

## Changeset

`@objectstack/plugin-approvals` patch, `Clause-②: no`. It says it
supersedes the "The SLA sweep keeps its reserved `system:sla` actor 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

- **Console rendering (objectui).** At the pinned sha, the inbox
timeline labels `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 the `escalate` row right before it, whose
comment names the action. The renderer change is objectui work, the same
carrier as objectstack-ai#21411's `acted_as` rendering. No reader needs a column the
row kind does not carry, so none is added.
- **ADR-0042 §2** ("Machine decisions → reserved actor `system:sla`",
and clients rendering it) is the decision ADR-0118 D1 supersedes for
this column. ADR-0042 has no amended status line, and `docs/adr/**` is
outside this PR's surface (Tier H). This needs a seat or maintainer
decision.
- **`ApprovalActionRow.reassign_from` / `reassign_to` TSDoc** in
`packages/spec/src/contracts/approval-service.ts` still says "the user
whose pending-approver slot was moved", and `*_name` says
`sys_user.name`. The type (`string`) is unchanged. The text needs a
spec-lane correction, and this PR makes no `packages/spec` edit.
- **`docs/qa/platform-checklist/areas/approvals.json`** (the SLA
single-shot item) says the `escalate` row's actor is `SLA_ACTOR_ID`. The
row now records no actor. The item needs a one-word change, outside this
PR's surface.
- **Behaviour change for integrations:** code that tested `actor_id` for
`system:sla` / `system:dead-run` reads the row kind instead, and code
that expanded `reassign_from` / `reassign_to` as `sys_user` reads them
as addresses. The changeset says both.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants