Skip to content

fix(service-automation): a runAs:'user' flow sees its organization's members in sys_user, as the user's own request does - #24495

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24448-run-context-org-user-ids
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24448-run-context-org-user-ids

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #24448
Clause-②: yes (widening: the exported RunIdentityContext / RunDataContext type gains the optional org_user_ids member, the key ExecutionContext already declares; no accepted input moves)

What was wrong

A runAs: 'user' flow's data context carried no org_user_ids. sys_user's shipped member scope is two policies in member_default: sys_user_self (id == current_user.id) and sys_user_org_members (id in current_user.org_user_ids). With the peer list absent, the second compiles unresolved-variable and drops out. So a get_record on sys_user in such a flow answered the acting user's own row only, while the same user's request answered their organization's members too.

Failing probe first

packages/services/service-automation/src/runas-user-org-peers.integration.test.ts boots the real stack: ObjectKernel, ObjectQLPlugin, driver-sql on better-sqlite3 :memory:, the identity objects, the real SecurityPlugin with its shipped default permission sets, and the real AutomationEngine with its CRUD nodes. Fixture: organization A (the Default Organization) and organization B. usr_me belongs to A and B and acts in A; usr_peer belongs to A; usr_other belongs to B only; usr_loner belongs to none.

Committed alone as 6ca644728 and run against the unchanged source:

case request path (control) flow before the fix
acting in A, record-change shape { userId, tenantId } usr_me, usr_peer expected [ 'usr_me' ] to deeply equal [ 'usr_me', 'usr_peer' ]
acting in A, the doors' shape (positions and permission sets already resolved) usr_me, usr_peer expected [ 'usr_me' ] to deeply equal [ 'usr_me', 'usr_peer' ]
no organization { userId } usr_me usr_me (already equal)

The control context is resolveUserAuthzGrants(ql, userId, { tenantId }) copied the way resolveAuthzContext copies it onto a request.

The change

  • Run setup seeds the peer list (AutomationEngine.resolveRunContext, engine.ts). For a runAs: 'user' run with a user, the engine asks its grants resolver whenever the context carries no org_user_ids yet, not only when it carries no permissions. When permissions were already forwarded (the REST trigger, action and endpoint doors forward them), ONLY the peer list is taken from the answer: the forwarded positions and permission sets stay verbatim, so an agent's narrowed ceiling is not re-broadened. A context that already carries a list (a subflow / map child, a resumed leg) keeps it and resolves nothing.
  • The bridge passes the resolver's list on (plugin.ts). The plugin's grants resolver already called resolveUserAuthzGrants, the resolver the request path seeds org_user_ids through, and dropped the field. It now returns it. FlowUserGrants gains an optional org_user_ids.
  • resolveRunDataContext stays the single mapping (runtime-identity.ts). Its runAs: 'user' branch copies the seeded list onto the data context (RunIdentityContext.org_user_ids, optional), so every data node and the lookup expander get it. No list is invented when none was seeded: the policies that read it drop out, which narrows. runAs: 'system' is unchanged.
  • The [runAs] warning on a resolver failure now says which half degraded (baseline member permissions, or only the peer list). The existing sentence baseline member permissions (not elevated) is kept for the full case.

Mechanism assumptions, measured

  • A1 holds: the const out: RunIdentityContext block had no org_user_ids; the probe above is the failing reading.
  • A2 refined: no trigger surface forwards org_user_ids (buildAutomationContext and dispatchFlowAction in @objectstack/runtime forward positions/permissions only when non-empty, the record-change trigger forwards userId + tenantId). So carrying a trigger's value could fix nothing on its own; the engine seeds at its one run-setup point for every producer. The approvals mirror's hand-built { isSystem: true, userId } write reaches a flow through the record-change trigger as { userId } (plus tenantId when the hook session has one), which the same seeding covers.
  • A3 holds: the resolver is async and engine-bound, so the seed is at run setup (resolveRunContext), not per node.
  • A4 holds, see "Hook path" below.
  • A5 holds: the list is sys_member rows with organization_id = tenantId plus the user, from the same resolveUserAuthzGrants call the request path makes. The fixture pins it: usr_other shares organization B with usr_me and is still absent when the run acts in A.

Ablations (each committed first, mutated through scripts/ablation-replace.mjs, restored to the HEAD blob with git diff HEAD empty)

  1. Drop the copy in resolveRunDataContext (if (orgUserIds) out.org_user_ids = orgUserIds; deleted): both organization cases red, expected [ 'usr_me' ] to deeply equal [ 'usr_me', 'usr_peer' ]; control and no-organization stay green.
  2. The "nothing beyond" pin, with the forbidden behaviour put back: the bridge returns the union of the user's peers across every organization they belong to. Three cases red: expected [ 'usr_me', 'usr_other', 'usr_peer' ] to deeply equal [ 'usr_me', 'usr_peer' ] (twice) and ... to deeply equal [ 'usr_me' ] for the no-organization run.
  3. Seed only when permissions are absent (the old condition): the doors' shape goes red in the integration file, and the two unit pins for that shape in crud-runas.test.ts go red.

Hook path: probed, not in this PR

A temporary probe (not committed) bound a runAs: 'user' hook that reads sys_user through ctx.api on the same real stack:

PROBE request-fired hook saw: ["usr_me","usr_peer"]
PROBE mirror-fired  hook saw: ["usr_me"]

The second write was the approvals-mirror shape { isSystem: true, userId, tenantId }. ScopedContext.withRunAs('user') (packages/objectql/src/engine.ts) pins { ...executionContext, isSystem: false }, so from a hand-built context the hook carries no positions, no permission sets and no org_user_ids. Repairing it needs an async resolution through the core resolver in engine.ts or hook-wrappers.ts, or the producers seeding the envelope in plugin-approvals. Neither is inside the rider surface the claim declared (hook-run-as.ts and its test), so the rider is cut and reported for domain:engine.

Tests and gates (head 304dea50d, which merges origin/main at ae6bd1a69)

  • pnpm --filter @objectstack/service-automation typecheck: exit 0 (tsc --noEmit, then check:test-typecheck: OK).
  • pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2: Test Files 190 passed (190), Tests 2541 passed (2541).
  • Derived gates (node scripts/pm/dispatch-gates.mjs --commands, 68 commands): 67 exit 0; pnpm check:dual-build-cjs-loads exit 3, PREREQUISITE NOT MET (it reads every package's dist/), NOT MEASURED here and left to Lint & Repo Gates. --ran reconciliation: 68 derived famil(ies) accounted for — 67 run, 1 NOT-MEASURED.
  • test:repo is not run locally: the diff edits no repo-project test, and Test Core runs it.
  • origin/main has moved 5 commits past the merge base since; none touches service-automation or the run identity path, so the branch is not re-merged before CI.

Acceptance notes

  • Where the list rides. It rides the run's AutomationContext object under org_user_ids, typed by a service-automation-local SeededRunContext, because the claim's file surface excludes packages/spec. The alternative is declaring the key on AutomationContext in @objectstack/spec/contracts, which would let the doors forward the request's list and skip the re-resolution below. Correctness does not need it. Carrier: none.
  • Cost. A run started from the REST trigger, action or endpoint doors now pays one grants resolution at setup (resolveUserAuthzGrants, cache bypassed as the existing bridge already rules). The record-change path already paid it.
  • Runs suspended before this release resume without the list (a resume does not re-run run setup) and keep today's answer, their own sys_user row, until they end.
  • Persistence. A paused runAs: 'user' run's sys_automation_run.context_json now carries the peer list beside the positions and permission sets it already carried.
  • Organization vetting. The request path vets a claimed organization against the user's memberships under a walled posture; the run takes the trigger's tenantId as handed. Every user-triggered door hands over an already-vetted one. Read-only inference, not probed.
  • Run context vs request context beyond this card. accessible_org_ids, systemPermissions and posture are not carried into a runAs: 'user' run's data context either; the group posture's wall reads accessible_org_ids. Read-only, not probed. Carrier: none.
  • Clause-② stays no as claimed. The only type movement is two optional members: RunIdentityContext.org_user_ids and FlowUserGrants.org_user_ids.

Seat's append (patch round 1)

domain:services seat 2 re-judged Clause-② from the diff: @objectstack/service-automation is access: public and its index re-exports RunIdentityContext / RunDataContext, so the new optional member is an additive widening of the published surface. Under the Check Changeset step's WHICH LEVEL rule (ruled 2026-09-04 on #15294) that takes minor. The declaration above and the changeset level are corrected in this round; the last Acceptance-notes line, which says it stays no, is superseded by this append.


Generated by Claude Code

…against the request path

The flow's data context carries no org_user_ids, so sys_user_org_members
drops out and the run sees only the acting user's row. Probe first; the
fix follows.

Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s
Co-authored-by: Claude <noreply@anthropic.com>
…n setup

A runAs:'user' flow's data context carried no org_user_ids, so the
shipped sys_user_org_members policy compiled unresolved-variable and
dropped out: the run saw only the acting user's own sys_user row while
the same user's request saw their organization's members.

Run setup now takes the peer list from the grants resolver the plugin
bridges to resolveUserAuthzGrants (the request path's resolver), also
when the trigger already carried positions/permissions (only the peer
list is taken then), and resolveRunDataContext passes it to every data
node. A context that already carries one keeps it.

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

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 45bec43cd981684906a0b4c1a8e15d08ca9ec09f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 45bec43cd981684906a0b4c1a8e15d08ca9ec09f

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

…IdentityContext member

The exported RunIdentityContext / RunDataContext type gains the optional
org_user_ids member, an additive widening of a published package's
public surface, so the level is minor and Clause-② is yes (widening).

Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 7e6f99f7a409fdb76cea099a4c232b94737e1095
Local-runs: none

The face is the Clause-②: yes declaration alone. The committed tree carries no packages/spec/src/** path and no governed path (check-governed-merges.mjs --pr 24495: NOT governed). Inputs: the diff, #24448 with its thread, ruling A′ on #24449 (6261536443) and this head's check-runs. 2026-10-19T08:51Z

① Derived judgments

  • @objectstack/service-automation publishes . only (exports → dist/index.*). Two members widen what that entry reaches; both are optional, both are correct, and both are covered by the PR's yes:
    • RunIdentityContext.org_user_ids?: string[] is reached through export type { RunDataContext, RunIdentityContext } and the exported resolveRunDataContext's return type. Its type equals the key spec already declares, ExecutionContext.org_user_ids (packages/spec/src/kernel/execution-context.zod.ts:337, z.array(z.string()).optional()).
    • FlowUserGrants.org_user_ids?: string[] is reached through the exported AutomationEngine.setUserGrantsResolver's resolver type. A host-supplied resolver may now return the list, and run setup stores it. The changeset's prose names only RunIdentityContext, which leaves this member out but states nothing false. Not blocking.
  • SeededRunContext and runOrgUserIds are exported from runtime-identity.ts but not from src/index.ts, and exports does not address the module. They are not on the published surface.
  • No input the package accepted before is refused. No key is renamed or retired.

② Semver level

'@objectstack/service-automation': minor, matching the Check Changeset step's WHICH LEVEL rule (ruled 2026-09-04 on #15294) for an additive widening of a published package's public surface. check-changeset-no-major and Check Changeset pass on this head.

③ Boundary flags

Implemented-by: claude/issue-24448-run-context-org-user-ids
Reviewed-by: session_01LR1cD9JqgkeqY7k9rrZH8s

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #24448 · domain:services seat 2 · os-warren · session_01LR1cD9JqgkeqY7k9rrZH8s · 2026-10-19T08:52Z · head 7e6f99f7a4, after patch round 1 (rework record 6262868933; report 6263382710).

  1. Form: draft, base main. The first line is Fixes #24448, and no other closing keyword sits beside any issue number. The second line is Clause-②: yes (widening: …), as the claim now reads. Not governed, 390 changed lines. The changeset is minor, and each sentence was checked against the diff.
  2. Premise, measured:
    • The defect reproduced on the real stack before the fix (6ca644728: expected [ 'usr_me' ] to deeply equal [ 'usr_me', 'usr_peer' ]).
    • The fix satisfies ruling A′ item 2. The seat read resolve-authz-context.ts: ctx.org_user_ids = grants.org_user_ids (about :737) and the starting value [userId] (about :1306). The run's bridge returns that same field unchanged.
  3. Gate check-runs on 7e6f99f7a4, latest run per name: 33 success, 7 skipped, 0 failed.
    • Lint & Repo Gates, every Type Check · job, Test Core 1/6–6/6, Build Core, Check Changeset and Governed Surface Queue Guard are all success.
    • The skips are Build Docs, Console Pin Gate, the Dogfood Regression Gate shard matrix, Dogfood Verify CLI, Packed-tarball smoke (opt-in) and Temporal Conformance, plus duplicate runs of Auto Label / Check PR Size, each of which has a success run on this head. check-expected-skips.mjs does not run here (no pnpm install: yaml missing), so the skips were judged row by row: NOT MEASURED as a tool exit.
    • The Clause-② review of record is 6263414965 (TIER_DEFAULT, PASS, this head).
  4. Accepted deviations:
    • The identity edits also land in engine.ts (resolveRunContext) and plugin.ts (the grants bridge). The claim named "the place in service-automation that builds the run's identity from its trigger", and those are it.
    • The peer list rides a package-local SeededRunContext over spec's AutomationContext, because packages/spec was outside the surface. The alternative is in the PR's Acceptance notes.
    • The pin "does NOT re-resolve when the trigger already carried permissions" is re-pinned: the resolver is now asked for the peer list only, and the forwarded envelope stays verbatim. A new case pins that a context carrying both resolves nothing.
    • pnpm check:dual-build-cjs-loads is NOT MEASURED locally (exit 3, no whole-repo dist); CI ran it.
  5. Out of scope: the hook path is filed as objectql: a runAs:'user' hook fired from a hand-built system context (plugin-approvals' writes) runs without org_user_ids, so sys_user's org-members scope drops out and the hook sees only the acting user's own row #24497, and platform-objects: the identity objects declare their default row scope (the literals the shipped sets copy today), and the registry refuses an extend contributor that loosens a platform object's default (#24164 ruling E′) #24341's seat is told on platform-objects: the identity objects declare their default row scope (the literals the shipped sets copy today), and the registry refuses an extend contributor that loosens a platform object's default (#24164 ruling E′) #24341. accessible_org_ids / systemPermissions / posture missing from a run context, and the spec AutomationContext declaration, stay in the PR's Acceptance notes (carrier: none).
  6. Landing: ordinary queue landing, now: pr_ready + automerge_enable through the relay. Then MERGED is confirmed on origin/main, pm:dispatched and the assignee are cleared on service-automation: a runAs:'user' flow's data context carries no org_user_ids, so sys_user's org-members row scope cannot resolve and the run sees only the acting user's own sys_user row #24448 with a landing note, and platform-objects: the identity objects declare their default row scope (the literals the shipped sets copy today), and the registry refuses an extend contributor that loosens a platform object's default (#24164 ruling E′) #24341's seat (its Blocked-by: #24448) is told.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 19, 2026 08:53
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 19, 2026 08:53
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit f72ce93 Oct 19, 2026
45 of 48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24448-run-context-org-user-ids branch October 19, 2026 09:24
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/m tests tooling

Projects

None yet

2 participants