Skip to content

fix(plugin-approvals): an approval's status write-back runs its automation as the person who acted, in the record's organisation - #24634

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-24519-approvals-context-identity
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-24519-approvals-context-identity

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24519
Clause-②: no (a producer fix inside @objectstack/plugin-approvals: no @objectstack/spec key, export, type or stored column moves; ruled by 6271152775)

The status write-back an approval makes (approvalStatusField) now runs the automation it fires as the person who acted, with that person's own request envelope in the record's organisation. Ruling 6271152775 on #24519 (maintainer: Gap 1 「A 带记录组织」, the cross-organisation approver 「x 按记录组织」, the live widening 「随 #24519 一起修」).

Landing order. The claim made this PR wait for PR #24527 (#24321), which edits the same file at the decision to resume hand-off. PR #24527 has merged (1b4078077), and this branch merges origin/main past it at 8b118b6b3. The two changes touch disjoint regions. The PR stays draft for the seat's review of record.

What was wrong

ApprovalService.mirrorStatusField wrote as { isSystem: true, userId, positions: [], permissions: [] }, with no organisation, on every door that names a person: submit (through the approval node), approve, reject, recall, send back (returned and auto-reject), and the action link. Phase 1 (report 6270420946) measured on a real stack what a runAs: 'user' consumer of that write received:

The docblock's reason for leaving the organisation off no longer held: since #10101 the request's organisation is the subject record's, and under single the driver scopes nothing. Its other claim, that the automation engine back-fills the run's tenantId, was false: the grants bridge answers a tenantId only when it was handed one.

The change

  • mirrorStatusField's named-actor branch writes under attributedMirrorContext(...): { isSystem: true, userId, tenantId } and nothing else.
    • tenantId is the subject record's LIVE organisation. It is read at write time, one by-id read of the organisation column, through recordOrgResolver, the resolver the request row was stamped by. The request's organisation is the fallback for an object (or a record) with none, as at the stamp.
    • No positions / permissions. The write itself is authorized by isSystem alone (the security middleware short-circuits before it reads a grant), so it still crosses the record lock and still lands a read-only status column. Every runAs: 'user' consumer now resolves the person's own envelope in that organisation.
  • The user-less branch (the dead-run sweep) keeps SYSTEM_CTX: a runAs: 'user' automation it fires is still refused.
  • Every attributed write acts in the record's organisation, an approver drawn from another organisation's directory (ADR-0105 D9) included. That is the reading PR feat(automation)!: an approval decision resumes a flow as the person who decided #24527 applies to a runAs: 'user' decider.
  • The six call sites each pass the request organisation they already hold (org / requestOrg) as the fallback: one argument each.
  • The docblock is rewritten, the false back-fill sentence included.
  • Untouched, as the claim fences: approval-node.ts, SYSTEM_CTX, the action-link context (Phase 1 measured that no write is made under any of them), and PR feat(automation)!: an approval decision resumes a flow as the person who decided #24527's resume hand-off.

Pins

packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts (new, 10 cases). It uses a real kernel, the real ObjectQL engine and hook wrappers, the real record-change trigger and automation engine, the real ApprovalService and approval node executor, and driver-sql on better-sqlite3 :memory:. The authz tables the resolver reads are spelled locally (the status-mirror-cascade precedent: no new dependency, no lockfile change), and the security catalog is bound with the package's test kit. The fixture is synthetic: two organisations; a person whose memberships differ between them; a person holding a grant the baseline lacks; an approver from the other organisation.

For each door that names a person, the envelope a runAs: 'user' hook and a runAs: 'user' record-change flow receive equals resolveUserAuthzGrants(person, { tenantId: ORG }) (positions, permission sets, peers), and the write carries isSystem, the person, ORG and no arrays:

  • open through the approval node (the submitter), approve, reject, recall, send back, send back past the revision limit, action-link redeem;
  • direction 1: the grant held beyond the baseline is carried (approve, reject);
  • direction 2: the person whose memberships differ carries only the record organisation's (open, recall);
  • the approver from another organisation acts in the record's organisation, with its members as peers;
  • a record re-parented while its request was pending is written, and cascades, in its new organisation;
  • CONTROL: the dead-run sweep stays user-less, the hook is refused (HOOK_UNSCOPED_DATA_ACCESS), and the flow runs with no person.

What the security plugin then enforces for an envelope stays pinned where the seed lives (service-automation's runas-user-hook-org-peers.integration.test.ts).

Moved pin, one: approval-service.test.ts › status mirror identity (#3783) asserted the mirror carries NO tenantId, which is the contract the ruling reverses. It now asserts the full context, { isSystem: true, userId: 'u1', tenantId: 't1' }, on that suite's schema-less engine double (the request's organisation as the fallback). The other nine pins of that describe are unchanged and green. Kin sweep: no other package test reaches the real mirror.

Ablation (fix committed first; scripts/ablation-replace.mjs on approval-service.ts; the suite imports the service from source, so no dist leg)

Both legs were predicted before running: 9 red (every door that names a person), the CONTROL green.

  • Leg 1, restore the empty arrays ({ ...SYSTEM_CTX, userId, tenantId }; blob 9ceb29f2c7f9 → d2258164b9de): 9 failed / 1 passed. Each fails at the hook's envelope: positions and permission sets [] where the person's own were expected.
  • Leg 2, drop the organisation ({ isSystem: true, userId }; blob 9ceb29f2c7f9 → 3522d1ac69f9): 9 failed / 1 passed. Each fails at the hook's envelope: tenantId undefined, peers = the person alone, and positions and permission sets that are not the record organisation's.
  • Each leg restored by the tool: blob after restore 9ceb29f2c7f9 == HEAD, git diff HEAD empty.

Tests and gates (final head 8b118b6b3, which merges origin/main at 65a8eeb7d)

  • pnpm --filter @objectstack/plugin-approvals test: 83 files, 1132 tests passed (TEST_EXIT=0, under scripts/pm/os-verify-lock.sh).
  • pnpm --filter @objectstack/plugin-approvals typecheck: exit 0; check:test-typecheck: OK (324 errors / 27 signatures held, debt unchanged).
  • Derived gates (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths, after the last commit): 69 commands, all 69 exit 0. --ran: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.
    • On the first pass, check:query-options-erasure and check:slot-lookup each flagged one new erasure in this diff. Both were fixed by typing (the record read no longer casts its options; the pin's engine lookup is typed ObjectQL), not by baselining.
    • check:i18n and check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET); after a full turbo run build both exit 0.
  • node scripts/check-nul-bytes.mjs: OK.
  • CI's families are read from this PR's check-runs.

Acceptance notes


Generated by Claude Code

…isation and carries no grants of its own

A named actor's mirror write now carries { isSystem, userId, tenantId }
with tenantId read live from the subject record (the request's
organisation for an object with none), and no positions / permissions,
so a runAs:'user' hook or record-change flow it fires resolves the
actor's own request envelope in that organisation.

Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s
Co-authored-by: Claude <noreply@anthropic.com>
…nAs:'user' cascade, door by door

Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s
Co-authored-by: Claude <noreply@anthropic.com>
…oducer's write shape

Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s
Co-authored-by: Claude <noreply@anthropic.com>
…ion as the actor in the record's organisation

Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s
Co-authored-by: Claude <noreply@anthropic.com>
…anisation, not its absence

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via approvals.getRequest (sdk, the route ledger binds it to GET /api/v1/approvals/requests/:id, selected by route anchor /approvals/requests/:id), getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id))
  • content/docs/api/plugin-endpoints.mdx (via /approvals/requests/:id (route, bridged from symbol sendBack — its route source's handler names it))
  • content/docs/automation/approvals.mdx (via getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id), /approvals/requests/:id (route, bridged from symbol sendBack — its route source's handler names it))
  • content/docs/automation/flows.mdx (via ApprovalService (symbol, a top-level class), /approvals/requests/:id (route, bridged from symbol sendBack — its route source's handler names it))
  • content/docs/kernel/services-checklist.mdx (via /approvals/requests/:id (route, bridged from symbol sendBack — its route source's handler names it))
  • content/docs/permissions/system-context.mdx (via sendBack (symbol, a method of class ApprovalService))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id))
  • content/docs/releases/v17/17-0.mdx (via getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id))
  • content/docs/releases/v17/17-6.mdx (via ApprovalService (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 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 973917c5795f7a440d46a15bc5cf8961af9d8703 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b17aecbd1ec03aaab04161f8f618faf2bcd518d2 — the merge of head 8b118b6b3672586c68e4fe31ef4146aaf7fcaec9 into base 973917c5795f7a440d46a15bc5cf8961af9d8703, 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 b17aecbd1ec03aaab04161f8f618faf2bcd518d2 && git checkout b17aecbd1ec03aaab04161f8f618faf2bcd518d2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 973917c5795f7a440d46a15bc5cf8961af9d8703 8b118b6b3672586c68e4fe31ef4146aaf7fcaec9 && git checkout -B drift-repro 973917c5795f7a440d46a15bc5cf8961af9d8703 && git merge --no-ff 8b118b6b3672586c68e4fe31ef4146aaf7fcaec9

node scripts/docs-audit/affected-docs.mjs --json 973917c5795f7a440d46a15bc5cf8961af9d8703

⚠️ 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 973917c5795f7a440d46a15bc5cf8961af9d8703 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #24519 · domain:services seat 2 · os-warren · session_01LR1cD9JqgkeqY7k9rrZH8s · 2026-10-19T18:48Z · head 8b118b6b36, the review of record for claim 6271647499 under ruling 6271152775.

  1. Form: draft, base main. The first line is Fixes #24519, and no other closing keyword sits beside an issue number. Clause-②: no, with its reason on the line. One patch changeset for @objectstack/plugin-approvals. 4 files, +503/−27.
  2. Premise, measured:
    • The ruled shape is the diff. A named actor's mirror writes under { isSystem: true, userId, tenantId }. tenantId is the subject record's live organisation, read through recordOrgResolver (the resolver the request row is stamped by), with the request's organisation as the fallback. No positions / permissions ride along. The sweep with no person stays on SYSTEM_CTX. decide() still hands resumedBy: actingUserId(context) to the engine, as PR feat(automation)!: an approval decision resumes a flow as the person who decided #24527 left it.
    • The seat's own reads: the hook contract types session.positions as optional (packages/spec/src/data/hook.zod.ts:1049), so an absent list stays inside the declared shape. No @objectstack/spec key, export or type moves, so Clause-②: no holds.
    • Ten new pins in status-mirror-envelope.integration.test.ts cover every door that names a person, in both directions, plus the cross-organisation approver, a record re-parented while pending, and the no-person sweep. Each pins the envelope that a runAs: 'user' hook and a runAs: 'user' record-change flow receive against resolveUserAuthzGrants for the same person in the record's organisation.
    • Ablation, two legs, each predicted 9 red with the control green: putting the empty arrays back gave 9 failed / 1 passed; dropping the organisation gave 9 failed / 1 passed. Each leg was restored to HEAD's blob with git diff HEAD empty.
  3. Gates:
    • The seat's diff read: 2 expect( lines removed and 37 added. The removed two are the #3783 pin's not.toHaveProperty('tenantId' | 'organizationId'), which the ruling reverses. The same case now asserts the exact shape { isSystem: true, userId: 'u1', tenantId: 't1' }.
    • Local at 8b118b6b36, after origin/main with PR feat(automation)!: an approval decision resumes a flow as the person who decided #24527 was merged in: the package's 83 files pass (1132 tests), typecheck exit 0 with the debt ledger unchanged, and the 69 derived families exited 0 (--ran: 0 NOT-MEASURED, 0 UNRUN). Two first-pass erasure flags were fixed by typing, not by the baseline.
    • CI on 8b118b6b36 at this read: 23 success, 6 skipped, 8 in progress, 0 failed. Landing waits for all of them.
  4. Accepted deviations:
  5. Out of scope: the service-automation system arm that carries empty arrays beside a user id is service-automation: a user-triggered runAs:'system' flow's data context carries empty positions / permissions beside the user id, so a runAs:'user' hook its writes fire runs on the member baseline instead of that user's own grants #24614, filed under ruling 6271152775 item 5 and now in the queue. It is not this PR's.
  6. Landing: ordinary queue landing, now that PR feat(automation)!: an approval decision resumes a flow as the person who decided #24527 has merged. When every check on the current head is green, the seat sends pr_ready + automerge_enable through the relay, then confirms MERGED and clears the card. approvals: after a recall, mirrorStatusField writes recalled into the record's approvalStatusField, a value its select does not declare, so an app whose submit gate reads that field never re-enters approval (measured on hotcrm, 18.0.0-next.5) #24577 (Blocked-by: #24519, the same function) is dispatchable once this card closes.

Generated by Claude Code

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/l tests tooling

Projects

None yet

2 participants