Skip to content

fix(objectql): a runAs:'user' hook sees its organization's members in sys_user, as the user's own request does - #24518

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24497-hook-runas-org-peers
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24497-hook-runas-org-peers

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #24497
Clause-②: no (a runtime producer fix: a runAs: 'user' hook reads what the same user's own request reads. Nothing @objectstack/spec declares moves; owes the seat's review of record before the queue)

What was wrong

ScopedContext.withRunAs('user') pins a hook's ctx.api to { ...triggering context, isSystem: false } and seeds nothing. A hook fired from a hand-built { isSystem: true, userId, tenantId } context therefore carried no org_user_ids. The sys_user_org_members policy (id in current_user.org_user_ids) compiled unresolved-variable and dropped out, so the hook saw the acting user's own sys_user row only. The same user's request saw their organisation's members too. This is the hook-side twin of #24448 (PR #24495), and a precondition of #24341: under its one-policy sys_user default, such a hook would lose its own row as well.

Measurement (real stack, before and after)

The probe is packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts. It boots ObjectKernel, ObjectQLPlugin, driver-sql on better-sqlite3 :memory:, the identity objects (createIdentityObjectsPlugin) and the real SecurityPlugin with its shipped default permission sets. A runAs: 'user' hook on hook_probe's afterInsert reads sys_user through ctx.api, and a middleware records the context that read carries.

Fixture: organisation A (the Default Organization) and organisation 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.

The probe was committed alone first (1f1afac08) and run against unchanged origin/main 666283aea. The after column is head e82725a18.

triggering context before: rows / hook context after: rows / hook context
{ isSystem: true, userId: usr_me, tenantId: A } (the card's shape) usr_me / no positions, no permissions, no org_user_ids usr_me, usr_peer / positions org_member, everyone, permissions member_default, org_user_ids usr_me, usr_peer
the request-resolved envelope (control) usr_me, usr_peer / envelope verbatim usr_me, usr_peer / envelope verbatim, nothing resolved
approvals' exact envelope { isSystem: true, positions: [], permissions: [], userId: usr_me } usr_me / org_user_ids absent (read from the ablation leg) usr_me / org_user_ids usr_me, positions and permissions [] verbatim
no organisation { isSystem: true, userId: usr_me } usr_me usr_me / org_user_ids usr_me

usr_other and usr_loner are never in any answer. The CONTROL case, the user's own request acting in A, answers usr_me, usr_peer on both trees.

The probe does not fire through plugin-approvals' real write path. No package composes approvals, the SecurityPlugin and the identity objects in its dev dependencies. plugin-approvals lacks plugin-security and plugin-auth; service-automation and runtime lack plugin-approvals; qa/dogfood and cli have all three but boot the full app. Adding a dev dependency would move the lockfile outside this card's fence. So the probe fires from the approvals' exact envelope, copied from SYSTEM_CTX in approval-service.ts and approval-node.ts.

The change

  • ScopedContext[SEED_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, the second half of the 'user' derivation. It applies fix(service-automation): a runAs:'user' flow sees its organization's members in sys_user, as the user's own request does #24495's run-setup rule to the hook's pinned context:
    • Seeding uses resolveUserAuthzGrants (@objectstack/core), the resolver the request path seeds through. There is no second resolver and no second definition of "peer".
    • A context with no permissions takes the resolved positions and permission sets.
    • Positions and permission sets the context already carries stay verbatim, and only the peer list is taken, so a narrowed ceiling is never re-broadened.
    • A context that already carries org_user_ids resolves nothing.
    • It reads no elevation flag: it runs only on the api withRunAs('user') produced.
  • seedHookUserGrants (hook-run-as.ts) is the step the wrapper awaits after deriveHookApi. An api that cannot seed, such as the refusing UnscopedHookApi, is returned as is.
  • installRunAsApi (hook-wrappers.ts) is now async for 'user' only.
    • 'inherit' takes no extra await and keeps the engine-built api by reference.
    • 'system' installs sudo() as before.
    • The UnscopedHookApi refusal (no userId) does not move.
    • When the resolver fails (an unreadable permission store), the hook keeps the unseeded pin and one warn names the consequence. This is narrower, never elevated, and the same posture as the flow run setup.

Why withRunAs stays synchronous. runtime's stored-metadata seams (deriveThroughSeam) wrap withRunAs's return value synchronously. A Promise there would hand back an unwrapped api, which is a route around the seam. So the async half is a separate method, reached by the wrapper while ctx.api is still the engine-built ScopedContext. The sandbox body layers wrap it later, at handler time.

Cost. A runAs: 'user' hook whose triggering context lacks permissions or org_user_ids pays one resolveUserAuthzGrants per dispatch: one wave of five concurrent reads, plus the permission-set reads the resolver makes. A request-resolved context carries both and pays nothing. The request-grants memo does not apply here, because it is open only inside resolveAuthzContext. The #11971 grants cache answers it when OS_AUTHZ_GRANTS_CACHE_TTL_MS is enabled; it is off by default. A hook is request-shaped, so it is not added to the cache's ruled bypass list.

Where the files landed vs the claim's fence. The three source files are in the fence. Outside it:

Pins

  • hook-run-as.test.ts, new [#24497] block (layer 1, a real ScopedContext over a recording engine that answers the resolver's reads). It covers:
    • the hand-built shape carries the organisation's peers and nothing beyond;
    • carried positions and permission sets stay verbatim and only the peer list is taken;
    • a context with a list resolves nothing;
    • no organisation gives the user alone;
    • 'system' and 'inherit' resolve nothing, and the user-less refusal holds;
    • a resolver failure keeps the unseeded pin and warns once.
  • Moved pin, one: 'user' DE-ELEVATES read engine.seen[0], which is now the resolver's first read. It now reads the hook's own update and still asserts isSystem: false and userId. Swept for kin: runtime/src/sandbox/hook-run-as.integration.test.ts pins only the user-less refusal for 'user', which does not move. Every other runAs test outside these files pins flows or 'system'.
  • The real-stack probe above (5 cases).

Ablation (committed first, mutated through scripts/ablation-replace.mjs)

  • Mutation: the seed (withUserGrantsSeeded at e82725a18; [SEED_USER_GRANTS] from f094a9d49, re-ablated in fix round 1) returns this unconditionally. The marker was proved in dist/ by ablation-dist-preflight.mjs, in 4 built files.
  • Integration: 2 red, the rest green:
    • expected [ 'usr_me' ] to deeply equal [ 'usr_me', 'usr_peer' ] (the card's shape);
    • expected undefined to deeply equal [ 'usr_me' ] (approvals' envelope).
    • The control and no-organisation cases stay green.
  • Unit: 4 red: the hand-built, verbatim, no-organisation and resolver-failure pins.
  • Restore: blob equal to HEAD, git diff HEAD empty, rebuilt, marker --absent from all 14 built files. 5/5 and 20/20 green again.

Tests and gates (head e82725a18, which merges origin/main at 3ae0b777d)

  • 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 the merge.
  • objectql: the 49 test files that reach ScopedContext, the hook wrappers, runAs or ctx.api: 49 passed, 860 tests.
    • The full objectql suite does not fit the container's 10-minute foreground cap (killed at 580 s). This narrowing is declared; Test Core runs the full suite.
  • service-automation runas-* and crud-runas: 6 files, 55 tests passed.
  • pnpm --filter @objectstack/core test:repo: 6 files, 82 tests passed. pnpm --filter @objectstack/objectql test:repo: 1 file, 5 tests passed.
  • Census rows: none owed. The [#22661] census keys on referenceTargetOf and federated-injected-column-readers keys on injected-column seams; this diff calls neither. Both pass.
  • Derived gates (node scripts/pm/dispatch-gates.mjs --commands, no paths, 72 commands): all 72 exit 0.
    • check:system-context-census first went red on a new elevation read in the seed method. The read was removed (e82725a18), not added as a census row.
    • check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET). After a full turbo run build, it exited 0.
    • --ran: 72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED.

Acceptance notes


Generated by Claude Code

@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

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

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

  • content/docs/api/error-handling-server.mdx (via ScopedContext (symbol, a top-level class))
  • content/docs/automation/hook-bodies.mdx (via ScopedContext (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via ScopedContext (symbol, a top-level class))
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 — 17 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 f4755a68a797d5a000cb8bd1c942cb2a9862108e → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json f4755a68a797d5a000cb8bd1c942cb2a9862108e

⚠️ 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 f4755a68a797d5a000cb8bd1c942cb2a9862108e → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e82725a185db27b45fcb83bc775d682855582db8
Local-runs: none
Scope: 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, .changeset/24497-hook-runas-org-peers.md + packages/core/src/security/resolve-authz-context.ts (resolveAuthzContext, vetOrganizationClaim, resolveUserAuthzGrants, ResolveUserAuthzGrantsOptions), packages/core/src/security/resolve-user-grants-cache.ts (openUserGrantsCache), packages/core/src/security/request-grants-memo.ts (openRequestGrantsMemo), packages/services/service-automation/src/engine.ts (resolveRunContext, FlowUserGrants), packages/services/service-automation/src/plugin.ts (the grants bridge), packages/plugins/plugin-security/src/security-plugin.ts (resolvePermissionSetsForContextUnmemoized), packages/plugins/plugin-security/src/objects/default-permission-sets.ts, packages/plugins/plugin-approvals/src/approval-service.ts (SYSTEM_CTX, mirrorStatusField, redeemActionToken), packages/plugins/plugin-approvals/src/approval-node.ts, packages/plugins/plugin-approvals/src/lifecycle-hooks.ts, packages/runtime/src/stored-metadata-reader-seam.ts (deriveThroughSeam), packages/runtime/src/sandbox/body-runner.ts (buildSandboxApi), packages/runtime/src/action-execution.ts, packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/contracts/scoped-context.ts (IScopedContext), packages/objectql/src/index.ts, packages/objectql/package.json, packages/core/src/security/second-object-read-exposure.pin.test.ts, scripts/check-system-context-census.mjs, .github/workflows/lint.yml, .github/workflows/pr-automation.yml (WHICH LEVEL), content/docs/automation/hooks.mdx, content/docs/automation/hook-bodies.mdx, content/docs/permissions/system-context.mdx. Base: merge-base 3ae0b777d4b94187abedc74f3ae316ff8e063e35; the diff is 6 files, +441/-10, matching the PR's file list. Card #24497 with every comment (triage 6263439195, claim 6263990919, dispatch 6264023108, report 6265315507, all read as claims), #24448 and PR #24495 with its review 6263414965 and rework record 6262868933, ruling A′ 6261536443 item 2, #24341's blocker note 6263655119, PR #24518's body, and the head's check-runs.

① Derived judgments

What a runAs: 'user' hook may read, before and after. The seed is ScopedContext.withUserGrantsSeeded (engine.ts), awaited by installRunAsApi (hook-wrappers.ts) through seedHookUserGrants (hook-run-as.ts) for 'user' only. Its two predicates are needsGrants = !Array.isArray(ctx.permissions) and needsPeers = !Array.isArray(ctx.org_user_ids); it returns this when neither holds, otherwise makes ONE resolveUserAuthzGrants(this.engine, ctx.userId, tenantId ? { tenantId } : {}) call and spreads the answer onto a copy of the pin. Per triggering shape:

  • Request-resolved (control): resolveAuthzContext copies positions, permissions and org_user_ids as arrays onto every request context (resolve-authz-context.ts ctx.org_user_ids = grants.org_user_ids, with the anonymous [] seed). Both predicates are false, zero reads, same envelope. Before equals after. Pinned at both layers (unit: reads(engine) empty; real stack: envelope verbatim). Right.
  • { isSystem: true, userId, tenantId } (the card's shape): before, withRunAs('user') pinned { ...ctx, isSystem: false } with no sets and no list; the security plugin applied its additive member baseline to the user-id'd context (resolvePermissionSetsForContextUnmemoized: positions ?? [], permissions ?? [], member_default added for a caller with a userId) and sys_user_org_members compiled unresolved-variable and dropped out, so the hook saw its own row. After, both predicates hold: positions, permission sets and the peer list (the members of tenantId plus the user, resolver step 5) are seeded; the baseline is still added on top, exactly as on the request. Measured [usr_me] to [usr_me, usr_peer], usr_other and usr_loner never present. Right.
  • Approvals' SYSTEM_CTX { isSystem: true, positions: [], permissions: [], userId }: Array.isArray([]) is true, so needsGrants is false and the empty arrays stay verbatim; needsPeers holds and the resolver, asked with no tenant, answers org_user_ids: [userId] (its seed value; step 5 runs only under a tenant). Before: no list. After: [usr_me], sets []. The empty-array treatment is the same as resolveRunContext's !Array.isArray(runContext.permissions) (service-automation engine.ts), so "carried positions and permission sets stay verbatim, only the peer list is taken" holds for empty arrays too. Pinned on the real stack (case 4). Right.
  • A narrowed agent ceiling: carries non-empty permissions, so it stays verbatim; only the peer list is added. Right. One shape neither twin guards: a context carrying positions but no permissions has its positions REPLACED (the predicate keys on permissions alone). No shipped producer hands that shape (the doors forward both or neither), and the replacement is the user's own resolved positions, so it is not a widening past the request. Noted, not blocking.
  • No organisation { isSystem: true, userId }: resolver with no tenant: org_user_ids: [userId], positions and sets from the global rows only (grantAppliesInTenant). Identical to an organisation-less request. Pinned at both layers. Right.
  • No userId: withRunAs('user') returns UnscopedHookApi; seedHookUserGrants hands it through (no withUserGrantsSeeded member); the data door still refuses HOOK_UNSCOPED_DATA_ACCESS; engine.seen is empty. Pinned. Right.
  • A context that already carries a list resolves nothing: needsPeers false; with sets present, return this. Pinned (zero find calls). Right.

Open question 1, the widening from the baseline to the user's real grants. It is exactly what the same user's request holds, and never more. resolveAuthzContext copies grants.positions and grants.permissions from the same resolver call onto a request; the security plugin adds the same baseline additively to both envelopes. The hook's pin is the request's envelope MINUS systemPermissions, accessible_org_ids and posture (not seeded, as on the flow side), so it is a subset. It cannot exceed the triggering context's ceiling anywhere: a trigger that carries a ceiling (non-empty permissions) keeps it verbatim, and a trigger with none was isSystem: true, which has no ceiling to exceed. Option A is right: one resolver, one rule for both producers, which is what ruling A′ item 2 asks.

The tenant the seed resolves under is the triggering context's tenantId, as handed, unvetted. The request path vets a claimed organisation against grants.accessible_org_ids under a walled posture (vetOrganizationClaim) and drops an unbacked claim before re-resolving; the hook has no posture to ask. So a producer that hands { userId: U, tenantId: T } with U not a member of T would give the hook T's members, which is more than U's own request under isolated or group. The list is always ONE organisation, never a union (step 5 reads sys_member for tenantId only; pinned by usr_other's absence). No shipped producer hands an unbacked tenant: approval-node.ts passes the flow context's own tenantId, mirrorStatusField and redeemActionToken pass none. This is the inference PR #24495's review accepted for flows ("the run takes the trigger's tenantId as handed"); accepted here with the condition named. grants.accessible_org_ids is in the same resolver answer if a later card wants the hook to vet.

Failure mode. The rejection arm of installRunAsApi installs derived, the unseeded { ...ctx, isSystem: false } pin, and warns once; nothing from a partial resolution is applied, because the resolver throws AuthzStoreUnavailableError before it returns. Policies reading org_user_ids drop out and the sets are unchanged (baseline where none). Nothing is widened relative to before the PR: fail-closed for access. The request path answers a store failure with 503 where the hook runs degraded with a warning; that divergence is the flow twin's accepted posture (resolveRunContext's catch). Pinned (org_user_ids undefined, one warn naming current_user.org_user_ids drops out). Right.

'inherit', 'system', the refusal. runAs === 'inherit' ? noRestore : await installRunAsApi(...) keeps 'inherit' free of any await and the api the same reference (pinned). 'system' still installs sudo(); its restore now comes back through Promise.resolve, one tick, no resolver read (pinned reads(sys) empty). The UnscopedHookApi refusal does not move. Right.

withRunAs stays synchronous. Two reasons, both real. RunAsDerivableApi.withRunAs is a synchronous member of a type exported from objectql's index, so an async return would be a contract change on a published type. And runtime's deriveThroughSeam wraps withRunAs's return value synchronously in its derivation walk; a Promise there would be wrapped as a Promise and the awaited context would be unserved. The ordering the PR relies on holds: the body runner's buildSandboxApiSource reads engineCtx.api at handler time, inside the wrapped hook after installRunAsApi has swapped in the seeded api, and buildSandboxApi wraps it then; the in-process hook api is not seam-served at all. Right. The consequence for the new member is in ②.

The grants cache, open question 3. openUserGrantsCache returns undefined when OS_AUTHZ_GRANTS_CACHE_TTL_MS is 0, the shipped default, so the seed resolves byte-identically to the uncached path. The bypass list is a ruled list of two sites with two reasons (the explainer verifying a revocation; long-lived automation runs that must not pin an envelope). A hook pins its api for one handler call and the cache retires entries on writes to the objects the resolver reads; neither reason applies. No bypass is right, and adding a third site to a ruled list without a ruling would itself be a deviation. The request memo is inert here (no resolveAuthzContext scope), as the PR body says.

The census. The first version read ctx.isSystem inside withUserGrantsSeeded and check-system-context-census refused it [site-without-a-row]; e82725a1 removed the read instead of anchoring a row. Right: the method runs only on the api withRunAs('user') built, whose isSystem is false by construction, so the read was a branch that could not fire, and a row would have documented a behaviour that does not exist. The census gate runs in Lint · repo & tooling gates (lint.yml) and is success on this head. Core's [#22661] census enumerates reads resolved through referenceTargetOf (second-object-read-exposure.pin.test.ts); callers of resolveUserAuthzGrants are not its population, and no pin counts those callers (nine non-test callers at the base). No row owed. Right.

#24341's precondition. On a runAs: 'user' hook's pin org_user_ids is now always an array (at least [userId]), except after a resolver failure. So under the one-policy default id == current_user.id || id in current_user.org_user_ids, a hook fired from approvals' envelopes keeps its own sys_user row (pinned on the real stack: org_user_ids: ['usr_me'] from the exact SYSTEM_CTX plus userId). Peers from mirrorStatusField need the producer to carry an organisation, which it deliberately does not; approval-node.ts already carries the flow's tenantId, so that producer's hook sees peers after this PR. Met, with the resolver-failure edge (no list, so the self arm drops too) that PR #24495's review accepted and told #24341's seat.

One derivation outside the seam's walk. deriveThroughSeam lists what a scoped API can derive (object, sudo, withRunAs, transaction, beginTransaction) so that "neither layer can leave a route around it". withUserGrantsSeeded is a new public derivation that walk does not know: a seam-served host action handler's ctx.api (action-execution.ts) calling it receives an unserved ScopedContext. Not an access widening (host code is deployer trust and reaches the store through its own imports; the body layers are the boundary and a body's api carries object and transaction only), but the published invariant gains an exception. Closed by keeping the member off the public face, see ②.

② Semver level

ScopedContext is exported from packages/objectql/src/index.ts, ObjectQL.createContext() returns it, @objectstack/objectql publishes with access: public, and nothing strips internal members from the built declarations. The new public async method is therefore a member of the published surface, reachable from an exported factory's return. Under the Check Changeset step's WHICH LEVEL rule (maintainer ruling 2026-09-04 on #15294) as PR #24495's patch round read it (rework record 6262868933, review 6263414965: an optional member added to an exported type is an additive widening, minor), a new public method on an exported class is at least that. As committed, '@objectstack/objectql': patch with Clause-②: no is the same misdeclaration #24495 corrected in its patch round. Check Changeset is success because patch and no are a consistent pair; it does not judge whether no is true.

The method can be kept off the public face, and that is the reviewer's call, because it also closes the seam-walk gap in ①: key the member on a symbol declared in hook-run-as.ts and not re-exported from index.ts (the wrapper already reaches it by duck-typing through seedHookUserGrants, and engine.ts already imports from ./hook-run-as.js, so no new import cycle), or any equivalent that leaves no nameable member on ScopedContext's declarations. Then the changeset stays '@objectstack/objectql': patch and the Clause-②: no (...) line stands as written in the changeset and on the PR body.

If the seat instead keeps the public method, the PR body's second line and the changeset's last line must both read Clause-②: yes (widening: ScopedContext, exported from objectql's index, gains the public async method withUserGrantsSeeded(); no accepted input moves) and the changeset's level must read '@objectstack/objectql': minor; the seam's derivation walk then owes the member as well, which is a runtime edit outside this card's fence.

Either way the head as committed does not hold on ②. The access face is accepted as it stands.

③ Boundary flags

  • engine.ts import line (resolveUserAuthzGrants added to the existing @objectstack/core import): in substance inside the fence, since it serves the ScopedContext member the claim named; not 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's predicate region, which has since landed on main with no conflict against this branch. Accepted.
  • The probe in service-automation (domain:services): a test file only, beside its flow twin, in the one package whose devDependencies compose the stack; no dependency or lockfile change. Accepted as a cross-lane test rider; the domain:services seat is told at landing.
  • No real approvals write path: the probe fires the exact envelopes I read (SYSTEM_CTX in approval-service.ts and approval-node.ts, plus userId). Accepted; the gap is stated in the PR.
  • Full objectql suite not run locally: Test Core 1/6 to 6/6 are success on this head. Accepted.
  • Trailers: the commits carry the model-free pair AGENTS.md prescribes; the harness-written trailer is AGENTS.md's exemption, not its rule. Not a deviation.
  • Open questions: 1 answered A (①). 2 answered in ②: the declaration as committed is wrong; the route is named. 3 answered A (①).
  • Out-of-scope finding 1 (approvals' envelopes carry no tenantId): half right. mirrorStatusField carries userId only, by documented design (tenantId is a driver-scoping knob on that write and would no-op the mirror cross-org). approval-node.ts DOES carry tenantId: context?.tenantId beside the non-envelope organizationId, so the finding's account of that producer is wrong; its hook sees peers whenever the flow context carries a tenant. The mirrorStatusField half meets filing gate ①, class (a): a named producer whose user context names no organisation, measured (own row where the user's request sees peers). Lane domain:services. The card is a decision on how an attribution-only write names the organisation it acts in, not "add tenantId". File, corrected.
  • Out-of-scope finding 2 (SYSTEM_CTX's empty arrays keep a hook on the baseline): right. Meets gate ①, class (a), domain:services, as the hook-side 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 gap for this producer; omitting the empty arrays when naming a user lets this PR's seed resolve. The direction worth stating on the card: for a user whose grants sit below the baseline (viewer_readonly is resolved instead of the member baseline), the baseline is WIDER than the user's request. That is the pre-existing shape, unchanged by this PR. File.
  • Out-of-scope finding 3 (systemPermissions, accessible_org_ids, posture not carried): carrier: none, the same note PR fix(service-automation): a runAs:'user' flow sees its organization's members in sys_user, as the user's own request does #24495 carried. No measured consequence; the group wall reading accessible_org_ids is what to measure first. Stays in the PR's notes, not filed.
  • Docs: content/docs/automation/hooks.mdx publishes 'user' as "the triggering user, so the hook can never exceed that user's grants", which stays true. The three drift-flagged pages name ScopedContext only. No docs edit owed.
  • Check-runs on e82725a1 (read 2026-10-19T11:10Z): 40, all completed; 34 success, 6 skipped (Build Docs, Console Pin Gate, the Dogfood shard matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance), 0 failed. Test Core 2/6 and 4/6 were in progress at my first read (11:01Z) and completed success. Lint & Repo Gates, every Type Check · job, Build Core, Check Changeset and Governed Surface Queue Guard are success.
  • Landing: not now. One patch round: take withUserGrantsSeeded off the public face (or declare the widening and raise the level, with the seam walk owed), then a new record on the new head. 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 that the precondition is met for approvals' envelopes, with the resolver-failure edge.

Implemented-by: claude/issue-24497-hook-runas-org-peers
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: FAIL


Generated by Claude Code

…t's nameable public face

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f094a9d49cf8d19e2847731684c243854db03621
Local-runs: none
Scope: 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, .changeset/24497-hook-runas-org-peers.md + packages/objectql/src/index.ts and packages/objectql/src/core.ts (the two package entries), packages/objectql/package.json (exports), packages/objectql/tsup.config.ts, packages/runtime/src/stored-metadata-reader-seam.ts (deriveThroughSeam), .github/workflows/pr-automation.yml (WHICH LEVEL). Delta review on the baseline record 6265937412 (head e82725a1, FAIL on ② only, the seat's ruling: route A). Inputs: that record; the dev's fix-round report 6266560805, read as claims; the net diff of this head against the merge base 3ae0b777d4b94187abedc74f3ae316ff8e063e35 compared per file with the old head's; this head's check-runs. One commit on top of the old head (f094a9d49c, parent e82725a185), no merge of main; a read-only merge-tree against origin/main is clean.

① Derived judgments

What moved, and what did not. The fix commit touches four files, +34/-9: engine.ts (the import gains SEED_USER_GRANTS, one comment, the member renamed from withUserGrantsSeeded() to [SEED_USER_GRANTS](), body unchanged), hook-run-as.ts (the symbol's declaration, the duck-typed interface's key, seedHookUserGrants's key), hook-wrappers.ts (one comment line, nothing executable), hook-run-as.test.ts (one new pin, 7 lines). The changeset and the real-stack probe are the same blobs on both heads (9ae6f9bd69, 3e0e73cb44), so every other path's net patch is byte-identical to the baseline's. The seed's body is byte-identical: the two Array.isArray predicates, the single resolveUserAuthzGrants(this.engine, ctx.userId, tenantId ? { tenantId } : {}) call, the spread onto a copy of the pin. installRunAsApi's executable text is unchanged, so 'inherit' without an await and by reference, 'system' through sudo(), the UnscopedHookApi refusal, the one-warn failure arm and the synchronous withRunAs all stand as judged in 6265937412 ①. Every access-face judgment of the baseline carries over unchanged; nothing in this round re-opens them.

withUserGrantsSeeded is gone from every nameable surface. The string does not occur in engine.ts, hook-run-as.ts, index.ts or core.ts at this head (only in comments of the baseline's wording, now rewritten). ScopedContext's string-keyed prototype members at this head are object, sudo, withRunAs, transaction and the rest of the base-tree class; the seed is its only symbol-keyed member. The new pin asserts both (getOwnPropertyNames(ScopedContext.prototype) excludes the old name; getOwnPropertySymbols(...) has length 1).

SEED_USER_GRANTS is not reachable from the package entry. package.json exports maps . to dist/index.* and ./core to dist/core.*, nothing else; tsup.config.ts builds exactly those two entries. index.ts re-exports from ./hook-run-as.js by name only (HookUnscopedDataAccessError, the two constants, HOOK_RUN_AS_VALUES, UnscopedHookApi, hookRunAs, deriveHookApi, and the three types), not the symbol; core.ts imports nothing from ./hook-run-as.js at all; neither entry carries an export *; hook-wrappers.ts and hook-binder.ts re-export nothing from it. The symbol is Symbol('objectstack.objectql.hookSeedUserGrants'), not Symbol.for(...), so it is not in the global registry and no string names it from outside the module. Declared as const SEED_USER_GRANTS: unique symbol, which is the form declaration emit needs for a computed member, so the built declarations can carry [SEED_USER_GRANTS](): Promise of ScopedContext beside an unexported declare const; the dev's .d.ts evidence (index.d.ts and core.d.ts name neither string; the symbol lives only in an internal chunk, outside its export list) is consistent with that reading and with the source. A consumer of @objectstack/objectql or @objectstack/objectql/core cannot write the member's name in types or import its key at runtime. What remains is reflection (Object.getOwnPropertySymbols on the prototype), which is not a declared surface and is the same class of reach every symbol-keyed member on every exported class already has, the seam's own markers included.

Under WHICH LEVEL this is no nameable widening. The rule's act is "a purely additive widening of a published package's public surface (a new exported symbol on an index, a new accepted key or value)". No new symbol is exported from either entry; no accepted key or value moves; RunAsDerivableApi, IScopedContext and every other exported type are unchanged. The baseline's reading (PR #24495's patch round: an optional member added to an exported type is a widening) turned on the member being nameable through an exported type; a member keyed on an unexported unique symbol is not. Clause-②: no is a true statement of this head.

The seam-walk gap closes. deriveThroughSeam's Proxy forwards an unknown function member by value.bind(target), so a caller HOLDING the symbol would still get an unserved context from a seam-served api. No host can hold it: it is unnameable from both entries and not in the global registry, and the one caller that holds it, seedHookUserGrants, runs inside installRunAsApi before the body runner wraps the api (the ordering 6265937412 ① verified). The published invariant "every context the scoped API derives is served" holds again for every derivation a host can name. The seam's walk does not need to learn the member.

The new face pin is load-bearing for the regression it names. 'SEED_USER_GRANTS' in (await import('./index.js')) is false: re-exporting the symbol from index.ts turns it red. getOwnPropertyNames(ScopedContext.prototype) not containing withUserGrantsSeeded: re-adding the string-keyed name turns it red. getOwnPropertySymbols(ScopedContext.prototype) of length 1: dropping the symbol member (the seed would then silently never run, since seedHookUserGrants hands through an api without it) or adding a second symbol-keyed member turns it red; the behavioural pins of the block (hand-built, verbatim, no-organisation, resolver failure) go red with it, as the dev's ablation through the new key shows (4 unit, 2 integration). Two limits, neither blocking: the entry check reads index.ts and not core.ts, the second entry, which today imports nothing from hook-run-as.ts; and a public method under a NEW string name would pass the second assertion, which only the reviewer's eye catches. Load-bearing.

② Semver level

'@objectstack/objectql': patch with Clause-②: no (...) now holds, on the changeset (unchanged blob) and on the PR body's second line. The published surface of @objectstack/objectql is the same set of nameable members and exported symbols as at the merge base; the fix is a runtime producer change inside it. Route A of 6265937412 ② is implemented as described there: the member is keyed on a symbol declared in hook-run-as.ts and not re-exported from the entry, the wrapper reaches it by the same duck-typed check, and engine.ts already imported from ./hook-run-as.js, so no import cycle was added. Check Changeset is success on this head. The alternative route (declare the widening, minor, teach the seam walk) is moot.

③ Boundary flags

  • PR body: still names ScopedContext.withUserGrantsSeeded() in "The change" and in the "Public surface" acceptance note. Not counted, as the seat is patching it to the symbol form; the dev's suggested wording in 6266560805 is accurate to this head.
  • No new deviation this round. The round touched four in-fence files; no dependency, lockfile, spec, docs or census row moved. The report's merge-tree claim is confirmed read-only from here (clean against origin/main).
  • The dev's correction to out-of-scope finding 1 (approval-node.ts carries tenantId beside organizationId; only mirrorStatusField is organisation-less, by design) matches the baseline record's reading. The two plugin-approvals findings go to domain:services as the baseline said: finding 1 corrected to the mirrorStatusField half, finding 2 as stated.
  • Face pin coverage: covers index.ts, not ./core. Not owed this round; a one-line addition if the entry pin is ever generalised.
  • Check-runs on f094a9d4 (read 2026-10-19T12:23Z): 47, all completed; 39 success, 8 skipped, 0 failed, none running. At my first read (11:56Z) 16 were in progress; Test Core (3/6) was the last, success. The skips are Build Docs, Console Pin Gate, the Dogfood shard matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance, and one run each of Auto Label and Check PR Size, both of which also have a success run on this head; the seven duplicated names are re-runs on the PR's edited event, each with a success run. Lint & Repo Gates, Lint · repo & tooling gates (the system-context census), every Type Check · job, TypeScript Type Check, Build Core (the declarations build), Check Changeset (both runs), Governed Surface Queue Guard and Test Core 1/6 to 6/6 (the new face pin runs there) are success.
  • Landing: on the gates' conclusions. 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 at landing that the precondition is met for approvals' envelopes, with the resolver-failure edge (6265937412 ①).

Implemented-by: claude/issue-24497-hook-runas-org-peers
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-19T12:26Z · ACCEPT

Head: f094a9d49c. The commits: the real-stack probe, the seed and its pins, the census-read removal, a merge of main, and the fix round that keys the seed on a module symbol. Card #24497 (p1, target:v18, area:access), claim 6263990919, dispatch 6264023108. Dev reports: 6265315507, fix round 6266560805.

Item Reading
Shape Draft PR → main. The body opens Fixes #24497. 6 files, +467/−11. Not governed.
Scope A runAs: 'user' hook whose triggering context carries no permissions or no org_user_ids is seeded where its runAs is resolved. The seed is ScopedContext[SEED_USER_GRANTS](), awaited by installRunAsApi through seedHookUserGrants. It makes one resolveUserAuthzGrants call under the trigger's tenantId, by PR #24495's rule: carried arrays stay verbatim, and a context that carries both resolves nothing. 'inherit', 'system' and the UnscopedHookApi refusal are unchanged, and withRunAs stays synchronous.
Seat's own read The fix round's diff is the rename to the symbol key, the symbol's declaration and one pin. The changeset and the probe are the same blobs. index.ts at the head names SEED_USER_GRANTS 0 times.
Review CONTRACT_REVIEW_TIER record 6265937412, FAIL on e82725a18 on ② alone (a public method on the exported ScopedContext). The seat ruled route A (off the public face). Delta record 6267161870 is PASS on this head; every access judgment of 6265937412 carries.
Declaration Clause-②: no on the claim, the PR body and the changeset (@objectstack/objectql patch). It holds now: no nameable member or exported symbol is added.
Cross-lane The real-stack probe in service-automation is declared to domain:services: 6265672952 on #6021 and 6265682549 on #21118. No objection. The PR body was patched to the symbol form by the seat.
Gates Dev, on f094a9d49c: 72 derived, 72 run, 72 exit 0. Core test:repo 82/82.
CI At this read on f094a9d49c: every check completed, none failed.

Disposition:

Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24497 then closes the card.


Generated by Claude Code

This was referenced Oct 19, 2026
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