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.

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