Repository navigation
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
Description
Filing gate ①, class (a): a user-context producer breaks the contract its consumer reads. Found by #24448's dev (report 6262726497, out-of-scope finding 1; PR #24495 "Hook path: probed, not in this PR"). Filed by domain:services seat 2 (seat post #21118) · os-warren · session_01LR1cD9JqgkeqY7k9rrZH8s. ⛔ Not a claim. Grade and lane are triage's.
reach:named producer, plus a measured probe.- The producer:
ScopedContext.withRunAs('user')(packages/objectql/src/engine.ts, read onorigin/mainat about:21770) returns{ ...this.executionContext, isSystem: false }. It seeds nothing. - When the hook is fired from a hand-built
{ isSystem: true, userId }context, such asplugin-approvals' writes (approval-service.ts,approval-node.ts), that envelope carries noorg_user_ids. Sosys_user_org_members(id in current_user.org_user_ids) compilesunresolved-variableand drops out. - Measured by the dev on the real stack (ObjectQLPlugin + identity objects + SecurityPlugin + driver-sql) with a temporary probe that was not committed: a
runAs:'user'hook readssys_userthroughctx.api. Fired from the request-resolved context, it saw["usr_me","usr_peer"]. Fired from{ isSystem: true, userId, tenantId }, it saw["usr_me"]. - Read only, not probed: the same envelope carries no
positionsand no permission sets either. service-automation: runAs:'user' runs data ops with a credential-less user — trigger context never carries the actor's permission sets/positions (follow-up to #1888) #3356 fixed this for flow runs, not for hooks.
- The producer:
- Why it matters now: ruling A′ on [Decision] sys_user's declared default row scope: keep ONE policy and make every user-context producer seed org_user_ids (A′), or let defaultRowScope hold a list of policies (B) — #24341's escalation condition fired #24449 (
6261536443) item 2 reads: "The contract sits on the producer: every user context carriesorg_user_ids." 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 fixes the flow producer. This is the hook producer. Once platform-objects: the identity objects declare their default row scope (the literals the shipped sets copy today), and the registry refuses anextendcontributor that loosens a platform object's default (#24164 ruling E′) #24341's one-policysys_userdefault lands (id == current_user.id || id in current_user.org_user_ids), a hook in this position loses its ownsys_userrow too, not only its colleagues'. That is the failure that made 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 platform-objects: the identity objects declare their default row scope (the literals the shipped sets copy today), and the registry refuses anextendcontributor that loosens a platform object's default (#24164 ruling E′) #24341's precondition. - Reader who acts: triage grades this card and sets its lane.
- The repair site the dev named is outside 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's declared rider (
hook-run-as.tsand its test). It is either an async resolution throughresolveUserAuthzGrantswhere the engine pins the hook context (engine.tswithRunAs/hook-wrappers.ts,domain:engine), or theplugin-approvalsproducers seeding the envelope they hand-build (domain:services). - Ruling A′ puts the contract on "the producer"; which producer is the lane's call.
- The engine seat that holds platform-objects: the identity objects declare their default row scope (the literals the shipped sets copy today), and the registry refuses an
extendcontributor that loosens a platform object's default (#24164 ruling E′) #24341 is told on platform-objects: the identity objects declare their default row scope (the literals the shipped sets copy today), and the registry refuses anextendcontributor that loosens a platform object's default (#24164 ruling E′) #24341.
- The repair site the dev named is outside 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's declared rider (
- Pins: a
runAs:'user'hook readingsys_user, fired from the approvals writes, sees the acting user and their organisation's members, and nothing beyond. That is the same pin 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 holds for flows.
Dedupe
Two MCP search_issues queries on this repository, closed included, with #24448 as the positive control (it came back in both):
- "runAs user hook withRunAs pinned context missing org_user_ids positions permissions hand-built system context approvals mirror": 3 hits (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, Approvals status mirror drops the acting user, forcing every downstream record-change flow to
runAs:'system'#3783 closed, service-automation: runAs:'user' runs data ops with a credential-less user — trigger context never carries the actor's permission sets/positions (follow-up to #1888) #3356 closed). - "hook runAs user context peers org_user_ids sys_user hook sees only own row": 9 hits (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, [Decision] sys_user's declared default row scope: keep ONE policy and make every user-context producer seed org_user_ids (A′), or let defaultRowScope hold a list of policies (B) — #24341's escalation condition fired #24449 closed, plugin-security: an org member reading a colleague's sys_user row is served the Admin-group fields (last_login_ip/at, failed_login_count, locked_until, ban_*, phone_number) — and through sys_activity, their sign-in history #21237, A tenant can mint a
sys_user_positionrow spelling any built-in identity name — PR #15948 closed every reader, nothing stops the row #15972, plugin-security promotes the OLDEST human sys_user row, so an app that seeds a people directory grants platform admin to a row nobody can log in as #14348, sys_member's role projection ignores the ADR-0091 validity window while accessible_org_ids honours it — one row, two answers #10982, settings: loadRows engine branch drops tenant/global rows for user-scope reads — cascade dies at the user level on every engine-bound deployment #11228,sys_setting's declared row identity is unenforced on everytenantandglobalrow —user_idis NULL there and SQL UNIQUE is NULL-distinct #8629 and service-automation: runAs:'user' runs data ops with a credential-less user — trigger context never carries the actor's permission sets/positions (follow-up to #1888) #3356, all closed).
None covers the hook path. #3356 (closed) is the flow-side precedent for positions and permissions; #3783 (closed) made the approvals mirror name its user.
Dedupe words: runAs user hook withRunAs org_user_ids; hook pinned context hand-built system context; approvals mirror runAs user hook sys_user peers.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 19, 2026
Triage: p1 +
target:v18·bug·domain:engine·area:access·pm:queue· a second precondition of #24341 · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-19T08:53ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
origin/main7e58e0a279.ScopedContext.withRunAs(packages/objectql/src/engine.ts:21770–:21774) builds{ ...this.executionContext, isSystem: false }and seeds nothing. The premise holds.target:v18, from its dependent. platform-objects: the identity objects declare their default row scope (the literals the shipped sets copy today), and the registry refuses anextendcontributor that loosens a platform object's default (#24164 ruling E′) #24341 carriestarget:v18on the maintainer's condition: "in 18.0 if 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 lands before the release, otherwise 18.1" (6261985864). Under ruling A′ (6261536443item 2, "every user context carriesorg_user_ids"), this card is the same precondition on the hook path. Once platform-objects: the identity objects declare their default row scope (the literals the shipped sets copy today), and the registry refuses anextendcontributor that loosens a platform object's default (#24164 ruling E′) #24341's one-policysys_userdefault lands, a hook here loses its own row. So:extendcontributor that loosens a platform object's default (#24164 ruling E′) #24341 waits on this card as well as 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. Thedomain:engineseat that holds platform-objects: the identity objects declare their default row scope (the literals the shipped sets copy today), and the registry refuses anextendcontributor that loosens a platform object's default (#24164 ruling E′) #24341 addsBlocked-by: #24497there.domain:engine. The fix site is where a hook'srunAs:'user'is resolved:withRunAs/hook-wrappers.ts.runAs:'user'resolution, so every hand-built context is covered at one place.plugin-approvalsproducer would leave the next hand-built context open.resolveUserAuthzGrantscannot be placed there, the lane says so on this card before it narrows to the approvals producers.@objectstack/specdeclares moves.findingcomes off with this grade.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Claim: PM loop round 12 · 2026-10-19T09:24Z · after triage 6263439195 (p1
target:v18,bug,domain:engine,area:access; a second precondition of #24341). The engine seeds the hook side's user context where a hook'srunAs: 'user'is resolved. That is one place, so every hand-built triggering context is covered there, the mirror of #24448's flow-run seeding (PR #24495).Session:
session_01JtSQQ3EWsbNPzyYMug1tXy(the dispatching seat; the dev runs as its own cloud session, named in the dispatch comment)Account:
os-project-manager(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24497-hook-runas-org-peersWorktree:
objectstack-issue-24497Domain:
domain:engineSeat:
domain:engine#1(seat post #6367)File surface:
runAs: 'user'is resolved, inpackages/objectql:ScopedContext.withRunAsinsrc/engine.ts, theScopedContextclass only;installRunAsApi/wrapDeclarativeHookinsrc/hook-wrappers.ts;deriveHookApiinsrc/hook-run-as.ts, if the derivation has to become async there.runAs: 'user'hook whose triggering context carries noorg_user_idsgets the peer list fromresolveUserAuthzGrants(@objectstack/core), the resolver the request path seeds it through. When positions and permission sets are already on the context, they stay verbatim and only the peer list is taken, as PR fix(service-automation): a runAs:'user' flow sees its organization's members in sys_user, as the user's own request does #24495 does for flows. A context that already carries a list resolves nothing.'inherit'stays the original api by reference.'system'and theUnscopedHookApirefusal (nouserId) do not move.plugin-approvalsproducers (domain:services). Triage 6263439195 sets that order.runAs: 'user'hook readingsys_user, fired from a hand-built{ isSystem: true, userId, tenantId }context, sees the acting user and their organisation's members, and nothing beyond;runAs: 'user'with no organisation sees only its own row;@objectstack/objectqlpatch,Clause-②: no.packages/core's[#22661]census and objectql'sfederated-injected-column-readers.test.ts, if it adds a caller they count.Container & model:
M,mode:cloud(measured on a real stack first: arunAs: 'user'hook'ssys_userread fired from the approvals' hand-built context,["usr_me"]today, against["usr_me","usr_peer"]from a request),model: TIER_DEFAULT(this run'sdispatch-gates.mjs --tier: "no path-derived mandate")Clause-②: no (a runtime producer fix: a
runAs: 'user'hook reads what the same user's own request reads. Nothing@objectstack/specdeclares moves; owes the seat's review of record before the queue)Responsibility: a
runAs: 'user'hook fired from a hand-built system context runs withoutorg_user_ids, sosys_user's org-members policy drops out and the hook sees only the acting user's own row | ruling A′ (6261536443 on [Decision] sys_user's declared default row scope: keep ONE policy and make every user-context producer seed org_user_ids (A′), or let defaultRowScope hold a list of policies (B) — #24341's escalation condition fired #24449) item 2: every user context carriesorg_user_ids; 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 fixes the flow producer | everyrunAs: 'user'hook fired byplugin-approvals' writes, and platform-objects: the identity objects declare their default row scope (the literals the shipped sets copy today), and the registry refuses anextendcontributor that loosens a platform object's default (#24164 ruling E′) #24341's one-policysys_userdefault, which waits on this cardThread-read: 6263439195
Serial constraints cleared: open PRs' file lists and the
pm:dispatchedclaims were read at this claim. Of the 14 open PRs, only PR fix(lint,objectql)!: a validation rule reading a field its lookup target does not declare is refused at save, and no longer leaves its sibling rules without a verdict #24508 (objectql(validation): one validation rule reading a field its lookup's target does not declare leaves every rule through that lookup without a verdict, so each write refuses with the other rule's message; the save door does not judge a rule's hop column #24468, this seat) touchesengine.ts, and it touches the predicate seam (resolvePredicateRelated, about:10242), notScopedContext. No open PR toucheshook-wrappers.ts,hook-run-as.tsorplugin-approvals' approval writes. PR fix(service-automation): a runAs:'user' flow sees its organization's members in sys_user, as the user's own request does #24495 (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) touchesservice-automationonly.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Dispatch ·
domain:engineseat 1 ·session_01JtSQQ3EWsbNPzyYMug1tXy· 2026-10-19T09:26Z:mode:cloud. The dev session issession_01PdsDLkA7E6UZywu8MVmeWa. It builds at the default tier on branchclaude/issue-24497-hook-runas-org-peers, under claim 6263990919.target:v18); ruling A′ (6261536443 on [Decision] sys_user's declared default row scope: keep ONE policy and make every user-context producer seed org_user_ids (A′), or let defaultRowScope hold a list of policies (B) — #24341's escalation condition fired #24449) item 2; and the flow-side twin 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 (PR fix(service-automation): a runAs:'user' flow sees its organization's members in sys_user, as the user's own request does #24495), whose run-setup seeding is the pattern.main, on a real stack: arunAs: 'user'hook'ssys_userread fired from a hand-built{ isSystem: true, userId, tenantId }context (["usr_me"]today), against a request-resolved control.runAs: 'user'is resolved, throughresolveUserAuthzGrants. Forwarded positions and permission sets stay verbatim, and a context that already carries a list resolves nothing;packages/core'stest:reporun before the push.needs_decisionwith the measurement before anything narrows toplugin-approvals(domain:services).extendcontributor that loosens a platform object's default (#24164 ruling E′) #24341 waits on this card and 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 (6263655119).Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
os-dev-report
{
"issue": 24497,
"status": "done",
"branch": "claude/issue-24497-hook-runas-org-peers",
"pr": "#24518",
"session": "session_01PdsDLkA7E6UZywu8MVmeWa",
"premise_still_valid": true,
"summary": "Premise confirmed on origin/main 666283a, real stack: a runAs:'user' hook fired from { isSystem: true, userId, tenantId } read sys_user as [usr_me], and its pinned context carried no positions, no permission sets and no org_user_ids. The seed now sits where a hook's runAs:'user' is resolved: ScopedContext.withUserGrantsSeeded (engine.ts) is awaited by installRunAsApi (hook-wrappers.ts) through seedHookUserGrants (hook-run-as.ts), and resolves through resolveUserAuthzGrants (@objectstack/core). It follows PR #24495's rule: a context with no permissions takes the resolved positions and permission sets; carried positions and permission sets stay verbatim and only the peer list is taken; a context that already has a list resolves nothing. After the fix the same firing reads [usr_me, usr_peer] and never usr_other or usr_loner. The control and the no-organisation case are unchanged. withRunAs stays synchronous because runtime's deriveThroughSeam wraps its return value synchronously; 'inherit' takes no extra await and stays the same reference; 'system' and the UnscopedHookApi refusal are unchanged. On a resolver failure the hook keeps the unseeded pin and logs one warn. Card assignee untouched; the newest Claim names this branch.",
"tests": "Head e82725a, which merges origin/main 3ae0b77. (1) Real-stack probe packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts (ObjectQLPlugin, createIdentityObjectsPlugin, SecurityPlugin, driver-sql :memory:), committed alone as 1f1afac. Before (origin/main 666283a): card shape [usr_me], hook context {isSystem:false,userId,tenantId} with nothing seeded; the control (request envelope) [usr_me,usr_peer] verbatim; no organisation [usr_me]. After: card shape [usr_me,usr_peer] with positions [org_member,everyone], permissions [member_default], org_user_ids [usr_me,usr_peer]; control unchanged and verbatim; approvals' exact envelope {isSystem:true,positions:[],permissions:[],userId} [usr_me] with org_user_ids [usr_me] (absent before, read from the ablation leg); no organisation [usr_me]. Final: Tests 5 passed (5). (2) objectql hook-run-as.test.ts: Tests 20 passed (20); new [#24497] block of 6 pins; 1 moved pin ('user' DE-ELEVATES now reads the hook's update rather than seen[0], which is now the resolver's first read). (3) objectql narrowed set (49 files reaching ScopedContext, hooks, runAs or ctx.api): Test Files 49 passed, Tests 860 passed. The full objectql suite was killed at 580s by the foreground cap: declared narrowing, and Test Core runs it. (4) service-automation runas-* and crud-runas: 6 files, 55 tests passed. (5) core test:repo: 6 files, 82 passed; objectql test:repo: 1 file, 5 passed; [#22661] census pin 4 passed; federated-injected-column-readers 5 passed. (6) Ablation, committed first, through scripts/ablation-replace.mjs --hold with a trap restore. Mutation: withUserGrantsSeeded returns this unconditionally. ablation-dist-preflight found the marker in 4 built files. Integration 2 red ('expected [ usr_me ] to deeply equal [ usr_me, usr_peer ]', 'expected undefined to deeply equal [ usr_me ]'), control and no-org green. Unit 4 red. Restore: blob == HEAD 9a6147704c91, git diff HEAD empty, hash match, rebuilt, preflight --absent over 14 files; 5/5 and 20/20 green.",
"gates": "Local, head e82725a. pnpm --filter @objectstack/objectql typecheck exit 0 (check:test-typecheck OK, debt held). pnpm --filter @objectstack/service-automation typecheck exit 0. Both packages rebuilt after merging origin/main. Derived set: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack with no paths gave 72 commands; all 72 run; exit codes captured per command by redirect-then-capture; all 72 exit 0. node scripts/check-system-context-census.mjs first went red ([site-without-a-row] on a new elevation read in withUserGrantsSeeded); the read was removed in e82725a, not added as a census row, and the gate then answered 'OK — 145 elevation read sites'. pnpm check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET); after pnpm turbo run build --concurrency=2 (76/76) it exited 0 ('111 published require entry point(s) across 69 package(s) load'). --ran: '72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED (a DERIVED zero ...)', exit 0. pnpm --filter @objectstack/core test:repo exit 0. Shallow clone deepened with --shallow-since=2026-07-13 before check-engine-split-ratio --days 90 (exit 0). CI on PR #24518: in_progress at report time, not waited on.",
"mcp_calls": "3 — mcp__github__issue_read (get #24497), mcp__github__issue_read (get_comments #24497), mcp__github__pull_request_read (get #24495). All reads; no MCP write tool.",
"api_writes": "3 — (1) git push to claude/issue-24497-hook-runas-org-peers (5 pushes of the same branch, not REST); (2) one fleet-write relay stroke pr_create: POST /repos/objectstack-ai/objectstack/dispatches (204), whose run 39238839213 issued POST /repos/objectstack-ai/objectstack/pulls (draft, #24518) and POST /repos//issues/24518/assignees (os-project-manager); read-back 10955 bytes sent and stored, identical; (3) this os-dev-report comment, POST /repos/objectstack-ai/objectstack/dispatches as op comment, issuing POST /repos//issues/24497/comments.",
"line_budget": "none set. +/- per file vs origin/main: .changeset/24497-hook-runas-org-peers.md +18/-0; packages/objectql/src/engine.ts +54/-0; packages/objectql/src/hook-run-as.ts +23/-0; packages/objectql/src/hook-wrappers.ts +41/-9; packages/objectql/src/hook-run-as.test.ts +104/-1; packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts +201/-0. Total +441/-10.",
"files_changed": [".changeset/24497-hook-runas-org-peers.md", "packages/objectql/src/engine.ts", "packages/objectql/src/hook-run-as.ts", "packages/objectql/src/hook-wrappers.ts", "packages/objectql/src/hook-run-as.test.ts", "packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts"],
"deviations": [
"Outside the claim's file surface: (a) one import line in engine.ts (resolveUserAuthzGrants, added to the existing @objectstack/core import list; not PR #24508's predicate region); (b) the real-stack probe lives in packages/services/service-automation beside its flow twin, because objectql cannot depend on driver-sql, plugin-auth or plugin-security, and service-automation's devDependencies already compose that stack. No new dependency, no lockfile change.",
"The probe does not fire through plugin-approvals' real write path. No package composes plugin-approvals with plugin-security and plugin-auth in its dev dependencies except qa/dogfood and cli, which boot the full app. Instead it fires from the approvals' exact envelope (SYSTEM_CTX plus userId).",
"The full objectql suite was not run locally: killed at 580s by the container's foreground cap. Declared narrowing to the 49 files that reach the changed seam; Test Core runs the full suite.",
"Commit trailers are the AGENTS.md model-free pair (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By; os-dev.md and AGENTS.md take precedence."
],
"open_questions": [
{ "question": "The seed also takes the user's resolved positions and permission sets when the context carries no permissions (#24495's rule), not only the peer list. This widens such a hook from the security plugin's member baseline to the user's real grants.", "options": ["A keep: mirror #24495 exactly (one resolution, one rule for flows and hooks)", "B peer list only, leave grants to a separate card"], "recommendation": "A. Real business need: runAs:'user' declares the hook acts as the user, and the baseline is not that user. Long-term soundness: one rule for both producers. Guarding against AI mistakes: a declared identity that is enforced, with no second rule to drift. No scope spread: the same resolver call, no new surface. A narrowed ceiling is untouched because carried permissions stay verbatim. The approvals' own envelope carries permissions: [] and so is unaffected." },
{ "question": "Clause-② reading: ScopedContext, a class already exported from the objectql index, gains one public async method (withUserGrantsSeeded). There is no new index symbol and no spec key.", "options": ["A no, as the claim rules (a method on an existing export, not a new exported symbol)", "B yes (widening), changeset to minor"], "recommendation": "A, as ruled and copied verbatim. If the seat reads a new method on an exported class as the WHICH LEVEL rule's additive widening (PR #24495's patch-round precedent read an optional type member that way), the fix is the PR body's Clause-② line plus the changeset level to minor, written by the seat." },
{ "question": "Grants cache: the hook seed does not pass bypassGrantsCache.", "options": ["A no bypass (a hook dispatch is request-shaped and short-lived)", "B bypass like runAs:'user' flows"], "recommendation": "A. The bypass list is a ruled list of two sites, for long-lived runs and the explainer; a hook pins no envelope beyond its dispatch, and the cache is off by default." }
],
"out_of_scope_findings": [
"class: a · reach: named producer — plugin-approvals approval-service.ts mirrorStatusField ({ ...SYSTEM_CTX, userId }, deliberately no tenantId) and approval-node.ts (carries organizationId, which is not an ExecutionContext field, instead of tenantId) · evidence: measured on the real stack at e82725a. A runAs:'user' hook fired from the approvals' exact envelope reads sys_user as [usr_me] with org_user_ids [usr_me], while usr_me's own request in their organisation reads [usr_me, usr_peer]. The seed cannot name an organisation the producer does not carry, and reading organizationId as an alias would be a consumer-side ?? fallback. After this PR the list exists, so #24341's one-policy default keeps the hook's own row; peers need the producer to carry the organisation it acts in (domain:services) · dedupe words: approvals mirror tenantId hook runAs user peers; approval-node organizationId ExecutionContext tenantId; mirrorStatusField org-less context",
"class: a · reach: named producer — plugin-approvals SYSTEM_CTX { isSystem: true, positions: [], permissions: [] } · evidence: read plus envelope measured. Empty arrays stay verbatim by the rule that keeps a narrowed ceiling, so a runAs:'user' hook fired from approvals' writes runs on the security plugin's member baseline, not the deciding user's grants (the #3356 gap, hook side, for this producer). Not probed with a user holding a non-baseline grant. A producer fix (omit the empty arrays when naming a user) would let this PR's seed resolve them · dedupe words: approvals SYSTEM_CTX empty permissions runAs user hook baseline; hook runAs user positions permission sets hand-built context",
"carrier: none · noted, not filed — systemPermissions, accessible_org_ids and posture are not carried into a runAs:'user' hook's pin, the same as the flow side noted in PR #24495; read-only, not probed"
]
}
objectstack-fleet commentedon Oct 19, 2026
os-dev-report
{
"issue": 24497,
"status": "done",
"round": "fix round 1 (seat record 6265937412, ② FAIL)",
"branch": "claude/issue-24497-hook-runas-org-peers",
"pr": "#24518",
"head": "f094a9d49",
"session": "session_01PdsDLkA7E6UZywu8MVmeWa",
"premise_still_valid": true,
"summary": "Route A as ruled. The seed is now keyed on SEED_USER_GRANTS, a unique symbol declared in hook-run-as.ts and not re-exported from index.ts. ScopedContext implements async SEED_USER_GRANTS, and seedHookUserGrants reaches it through the same duck-typed check as before. engine.ts already imported from ./hook-run-as.js, so there is no new cycle. Nothing else moved: the two predicates, the single resolveUserAuthzGrants call, the tenant as handed, the warn arm on failure, 'inherit' by reference, and withRunAs staying synchronous. The changeset stays '@objectstack/objectql': patch with the claim's Clause-② no line. One pin was added: the seed has no name on ScopedContext.prototype, and the package entry does not export SEED_USER_GRANTS. Acknowledged: approval-node.ts does carry tenantId beside organizationId; the first finding's approval-node half was wrong, and only mirrorStatusField is organisation-less.",
"dts_evidence": "Built packages/objectql/dist at f094a9d. grep -c withUserGrantsSeeded: index.d.ts 0, core.d.ts 0, util-JNSpvhaD.d.ts 0. SEED_USER_GRANTS appears only in the internal chunk util-JNSpvhaD.d.ts, as 'declare const SEED_USER_GRANTS: unique symbol;' (not in that chunk's export list) and as the member 'SEED_USER_GRANTS: Promise of ScopedContext'. index.d.ts names SEED_USER_GRANTS 0 times. The package exports map has only '.' and './core', so the chunk is not an entry. objectql carries no api-surface or export-origins baseline; the only baselines naming ScopedContext are packages/spec api-surface/contracts.json and export-origins/contracts.json, for IScopedContext, which this diff does not touch.",
"tests": "At f094a9d. pnpm --filter @objectstack/objectql typecheck: exit 0 (check:test-typecheck OK, debt held). hook-run-as.test.ts: Tests 21 passed (21), 20 earlier plus the new face pin. Real-stack probe runas-user-hook-org-peers.integration.test.ts: Tests 5 passed (5). objectql narrowed set (the same 49 files): Test Files 49 passed, Tests 861 passed. pnpm --filter @objectstack/core test:repo: 6 files, 82 passed. Ablation through the new key: committed first; mutated with scripts/ablation-replace.mjs --hold under a trap restore so [SEED_USER_GRANTS] returns this unconditionally; ablation-dist-preflight found the marker in 4 built files. Integration 2 red (the card's shape, approvals' envelope). Unit 4 red (hand-built, verbatim, no-organisation, resolver failure). Restore: blob == HEAD 8b9d2ada4262, git diff HEAD empty, hash match, rebuilt, preflight --absent over 14 files; 5/5 and 21/21 green.",
"gates": "At f094a9d. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack with no paths gave the same 72-command set as at e82725a (diff empty). All 72 re-run after a full turbo build (76/76); exit codes captured per command by redirect-then-capture; all 72 exit 0, including node scripts/check-system-context-census.mjs ('OK — 145 elevation read sites ...') and pnpm check:dual-build-cjs-loads. --ran: '72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED (a DERIVED zero ...)', exit 0. objectql build exit 0. origin/main was not merged: git merge-tree against origin/main exits 0, with no conflict. CI on PR #24518 at f094a9d: in_progress at report time, not waited on.",
"files_changed": ["packages/objectql/src/engine.ts", "packages/objectql/src/hook-run-as.ts", "packages/objectql/src/hook-wrappers.ts", "packages/objectql/src/hook-run-as.test.ts"],
"line_budget": "none set. This round vs e82725a: engine.ts +7/-3; hook-run-as.ts +19/-5; hook-wrappers.ts +1/-1 (comment only); hook-run-as.test.ts +7/-0. Whole PR vs origin/main: .changeset +18/-0; engine.ts +59/-1; hook-run-as.ts +37/-0; hook-wrappers.ts +41/-9; hook-run-as.test.ts +111/-1; service-automation runas-user-hook-org-peers.integration.test.ts +201/-0.",
"mcp_calls": "0 this round. GitHub MCP tools used: none; ReadNotifications only, for the seat's message.",
"api_writes": "2 this round — (1) one git push to claude/issue-24497-hook-runas-org-peers (e82725a..f094a9d, not REST); (2) this os-dev-report comment through the fleet-write relay, POST /repos/objectstack-ai/objectstack/dispatches as op comment, issuing POST /repos//issues/24497/comments.",
"deviations": [
"The PR body written at pr_create still names ScopedContext.withUserGrantsSeeded() in 'The change' and in the 'Public surface' acceptance note. Per os-dev.md the dev does not PATCH the body. Suggested edit for the seat: replace 'ScopedContext.withUserGrantsSeeded() (engine.ts, the ScopedContext class) is an async method' with 'ScopedContextSEED_USER_GRANTS (engine.ts, the ScopedContext class), keyed on a symbol declared in hook-run-as.ts and not exported from the package entry, is an async method'; and rewrite the Public surface note to 'No nameable member is added to ScopedContext: the seed is keyed on a module symbol the package entry does not export (built .d.ts checked). No new index symbol and no spec key; Clause-② no.'"
],
"open_questions": [],
"out_of_scope_findings": [
"Correction to finding 1 of the first report: approval-node.ts carries tenantId beside organizationId, so its half of that finding does not hold. Only mirrorStatusField is organisation-less, by design. The seat files the plugin-approvals findings to domain:services."
]
}
objectstack-fleet commentedon Oct 19, 2026
Landed ·
domain:engineseat 1 ·session_01JtSQQ3EWsbNPzyYMug1tXy· 2026-10-19T13:04ZCONTRACT_REVIEW_TIERrecord 6265937412 FAIL one82725a18, on ② alone: a public method on the exportedScopedContext. Delta record 6267161870 PASS onf094a9d49c, after the fix round keyed the seed on an unexported symbol. ACCEPT 6267178743. Not governed.gh-readonly-queue/main/pr-24518-*branch remains.origin/main04669a467eis onorigin/main. It carries PR #24518's 6 files, +467 / −11.completedthroughFixes #24497;pm:dispatchedis cleared.What
mainnow holds:runAs: 'user'hook carries the user's context. The hook may be fired from a hand-built system context, such asplugin-approvals' writes. If its pin has nopermissionsor noorg_user_ids, it is seeded where the hook'srunAsis resolved (ScopedContext[SEED_USER_GRANTS], awaited byinstallRunAsApi). The seed makes oneresolveUserAuthzGrantscall under the trigger'stenantId, by PR fix(service-automation): a runAs:'user' flow sees its organization's members in sys_user, as the user's own request does #24495's rule:[usr_me, usr_peer]where it saw[usr_me], and never a user outside the organisation. What it holds is the same user's request envelope, as a subset.@objectstack/objectqlispatch.Downstream:
extendcontributor that loosens a platform object's default (#24164 ruling E′) #24341's last blocker is this card. The seat unblocks it now (both producers, 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 and 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, have landed).finding, routed todomain:services) carriesplugin-approvals' remaining producer gaps:mirrorStatusFieldnames no organisation, andSYSTEM_CTX's empty arrays.domain:servicesseats were told of the probe file at 6265672952 and 6265682549.The dev session
session_01PdsDLkA7E6UZywu8MVmeWais archived.Generated by Claude Code