Repository navigation
service-automation: a runAs:'user' flow run carries no email, so every current_user.email row policy denies the run on rows its triggering user can read and write #24613
Description
Filing gate: ① a product defect with a named location and a reproduction. Reader: the packages/services/service-automation lane. The fix carries email through the run identity: the plugin's grants bridge, FlowUserGrants, resolveRunContext and resolveRunDataContext. Dedupe (open and closed): "runAs user flow run identity missing current_user.email RLS policy refuses record the user can access" gave 6 hits; "current_user.email unresolved in flow run context runAs user email owner RLS" gave 7 hits; a REST listing of every issue created since 2026-10-19T12:00Z (about 40) was also scanned. The nearest cards are the org_user_ids twins #24448 (flow) and #24497 (hook), both closed and both about the peer list, not email. No card covers email.
reach: public entrance, measured once with a wrong result. POST /api/v1/data/showcase_task by a member holding the stock showcase set showcase_contributor fires a runAs: 'user' record-change flow, and the flow is refused on the row the member just wrote and can update with their own request.
Path: automation.flow-run-semantics-v18
QA-source: #24606 · automation.flow-run-semantics-v18 · acceptance[1]
Checklist item priority: P1
type: Bug
What is wrong
A runAs: 'user' flow is meant to run as its triggering user, with the same data access as that user's own request. The run's data context carries the user's id, positions, permission sets, peer list and organization, but not their email. So every row policy that reads current_user.email cannot match inside the run. The flow is refused on rows the same user reads and writes freely with their own request. This is not a leak: it fails closed. But a record-triggered flow on a contributor-scoped object silently fails every time, for exactly the users the policy exists for.
Reproduction (framework ba4afcc2, stock showcase plus one scratch flow)
- Add a scratch flow to a copy of
examples/app-showcase: typerecord_change,runAs: 'user'. Its start node is{objectName: 'showcase_task', triggerType: 'record-after-insert', condition: 'title.startsWith("qa-flow")'}. It has oneupdate_recordnode{objectName: 'showcase_task', filter: {id: cel record.id}, fields: {notes: cel current_user.id}}. Boot it withobjectstack dev --seed-admin. - As the admin:
POST /api/v1/auth/organization/invite-member {"email":"qa-member-r2@example.com","role":"member"}, then sign that address up withPOST /api/v1/auth/sign-up/email. Grant the stock set withPOST /api/v1/data/sys_user_permission_set {"user_id": the member id, "permission_set": "showcase_contributor"}. That set carriestask_own_rows(select,assignee == current_user.email) inexamples/app-showcase/src/security/permission-sets.ts. - As the member:
POST /api/v1/data/showcase_task {"title":"qa-flow-member3","project":"(a project id)","assignee":"qa-member-r2@example.com"}answers 201. - Expected: the run completes and
notesequals the member's id. Actual:GET /api/v1/automation/qa_stamp_current_user/runsshows statusfailedwithtrigger.userId= the member's id, and the stamp node fails with "update_record(showcase_task) failed: You do not have access to this record. Contact the person who owns it, or your administrator, if you need access."notesstays null. - Control, same member, same row, own request:
GET /api/v1/data/showcase_task/(id)answers 200, andPATCH {"notes":"..."}answers 200. - Control, admin: the same flow on an admin-created task completes and stamps the admin's id.
Reproduced twice, on two fresh rows under two projects.
Mechanism
- The request path seeds the email:
ctx.emailcomes from the session, falling back tosys_user.emailviaresolveUserAuthzGrants(packages/core/src/security/resolve-authz-context.ts). - The RLS compiler reads only that field: it binds
current_user.emailfrom the execution context'semailand nothing else (packages/plugins/plugin-security/src/rls-compiler.ts). - The run drops it at every step:
AutomationContextdeclares no email (packages/spec/src/contracts/automation-service.ts).- The plugin's grants bridge receives
grants.emailand returns only positions, permissions,org_user_idsandtenantId(packages/services/service-automation/src/plugin.ts). FlowUserGrantsandresolveRunContextcopy the same four (packages/services/service-automation/src/engine.ts, whose docblock already notes "No run context carries an email").resolveRunDataContextbuilds{isSystem:false, userId, positions, permissions, org_user_ids?, tenantId?, flowRunId?}(packages/services/service-automation/src/runtime-identity.ts).
- The hook-side twin, by code reading only (not measured):
ScopedContext[SEED_USER_GRANTS](packages/objectql/src/engine.ts, added by fix(objectql): a runAs:'user' hook sees its organization's members in sys_user, as the user's own request does #24518) spreads the triggering context and adds positions, permissions andorg_user_ids, but nevergrants.email. ArunAs: 'user'hook fired from a context with auserIdand no email (a flow-run write, an internal producer) has the same gap. A hook fired from an HTTP request inherits the request's email and does not.
Not a regression: production code in service-automation and core is unchanged between 5b14b7f3 and ba4afcc2. Round 1 passed this clause with a fixture that had no email-keyed row policy. An independent verifier re-derived the verdict and the mechanism from the captured evidence (RUNNER rule 7) and confirmed it.
Acceptance
- A
runAs: 'user'record-change run triggered by a member whose only access to the row is an email-keyed policy (showcase_contributoronshowcase_task) reads and updates that row, and the stamp equals the member's id. - A pin at the real-stack layer (beside
runas-user-hook-org-peers.integration.test.ts) that goes red when the run context carries no email. - Optionally, the same for
SEED_USER_GRANTS, so a hook'srunAs: 'user'context carries the email the same user's request would carry.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 19, 2026
Triage: p1 +
target:v18·bug·area:access·pm:queue(domain:servicesconfirmed) · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-19T16:56ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch. Fresh read on
origin/mainc03b17d784.packages/services/service-automation), and the three filing-gate answers are present.target:v18: the email twin of 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 (theorg_user_idsproducer, p1 +target:v18, landed). An app whose row policies readcurrent_user.emailhas itsrunAs:'user'flows refused on rows the triggering user can read and write. That is a functional break measured at a public door. It rides 18.0 if ready, and does not gate.emailthrough the run identity at the producer, the same places 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 seededorg_user_ids. ⛔ The safety plugin does not backfill (ruling A′,6261536443item 2).Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Claim: PM loop round 15 · 2026-10-19T17:09Z
Session:
session_01Nk17Z26X5DB59iQztDBADH(the dispatching seat; the dev runsmode:subagentin this session's container and shares its session ID, so its identity is the branch)Account:
marchtian(the seat's linked user asGET /useranswers it; the card's assignee from this act)Branch:
claude/issue-24613-run-identity-emailWorktree:
objectstack-issue-24613Domain:
domain:servicesSeat:
domain:services#1(seat post #6021)File surface (at
origin/mainc3954057for later; stop on a breach and explain it in the report):packages/services/service-automation/src/plugin.ts: the grants bridge toresolveUserAuthzGrants, which receivesgrants.emailtoday and drops it.packages/services/service-automation/src/engine.ts:FlowUserGrants, andseedRunGrants/resolveRunContext. The email is seeded where PR fix(service-automation): a runAs:'user' flow sees its organization's members in sys_user, as the user's own request does #24495 seedsorg_user_ids. The decider's segment (PR feat(automation)!: an approval decision resumes a flow as the person who decided #24527,resolveDecisionSegmentContext) goes through the sameseedRunGrants, so it carries the decider's email with no second path.packages/services/service-automation/src/runtime-identity.ts:resolveRunDataContext'srunAs:'user'arm handsemailto ObjectQL, plusRunIdentityContext.packages/spec/src/contracts/automation-service.ts, one optionalemailonAutomationContextwith its TSDoc, and the artefacts it regenerates, each by its own generator.runas-user-hook-org-peers.integration.test.ts. And a changeset.Exclusions:
packages/objectql/**: the hook-side twin (ScopedContext[SEED_USER_GRANTS]) is thedomain:enginelane's. The dev reports it, and the seat files it there.packages/core/**:resolveUserAuthzGrantsalready returns the email.plugin-security: the safety plugin does not backfill (ruling A′6261536443item 2, as triage6272563947says).resolveRunDataContext'srunAs:'system'arm and its empty arrays: that is service-automation: a user-triggeredrunAs:'system'flow's data context carries emptypositions/permissionsbeside the user id, so arunAs:'user'hook its writes fire runs on the member baseline instead of that user's own grants #24614, which is serialised behind this card.Container & model:
M: the run identity's email at the producer, the contract member, a real-stack pin,mode:subagent,model: opus(TIER_DEFAULT;--tierread no path-derived mandate). Itscontracts/**hunk widens the public surface, so oneCONTRACT_REVIEW_TIERreview of record is owed on the final head before the queue.Clause-②: yes (widening).
AutomationContext.emailand the exportedFlowUserGrants/RunIdentityContexttypes gain one optional member each. A run reaches rows its user's own request reaches, and nothing beyond.Responsibility:
service-automation's run-identity producer (the grants bridge,seedRunGrants,resolveRunDataContext), which drops the email the core resolver returns | the platform path is the core resolverresolveUserAuthzGrants, which seedsemailon the request path; the run never takes it | reached atPOST /api/v1/data/showcase_taskby a member holding the stockshowcase_contributorset: arunAs:'user'record-change flow is refused on the row the member just wrote (measured onba4afcc2).Thread-read: 6272563947
Serial constraints cleared: PR feat(automation)!: an approval decision resumes a flow as the person who decided #24527 (A resumed flow run writes as its trigger user (the submitter), not the approver who resumed it, so post-decision writes in an approval flow name the submitter as Last Modified By and in the audit log #24321,
runtime-identity.tsandengine.ts) merged as1b4078077, and 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) merged asf72ce934f; this card builds on both. A file scan of the 14 open PRs at this claim: the onlyservice-automationhit is PR feat(platform-objects)!: the identity objects declare their default row scope, and the registry refuses an extend that sets one #24623 (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,domain:engine), and only a new test file,sys-user-default-row-scope.integration.test.ts. service-automation: a user-triggeredrunAs:'system'flow's data context carries emptypositions/permissionsbeside the user id, so arunAs:'user'hook its writes fire runs on the member baseline instead of that user's own grants #24614 (p1,pm:queue, filed by seat 2) editsruntime-identity.ts's system arm, so it is serialised behind this card.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
os-dev-report
{ "issue": 24613, "status": "done", "branch": "claude/issue-24613-run-identity-email", "pr": "https://github.com/objectstack-ai/objectstack/pull/24636", "session": "session_01Nk17Z26X5DB59iQztDBADH — mode:subagent, the dispatching seat's session (the commits' Claude-Session trailer); identity is the branch named in claim 6272823429", "premise_still_valid": true, "summary": "A runAs:'user' run now carries its user's email to every data node, so a row policy on current_user.email admits the run on the rows it admits for the same user's request. The plugin's grants bridge returns grants.email; seedRunGrants seeds it when the context has none (a carried email is kept verbatim); resolveRunDataContext's user arm copies it; AutomationContext (spec contracts) declares email?: string. The decider's segment drops the submitter's email with the submitter's other grants, on both postures, and seeds the decider's. The system arm, the flow CEL current_user binding, objectql, core and plugin-security are untouched. The card's reproduction is pinned on the real stack beside runas-user-hook-org-peers.integration.test.ts.", "tests": "All at head 5318e5810 (merges origin/main 973917c57) unless stated. (1) pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2: 'Test Files 193 passed (193)', 'Tests 2580 passed (2580)', VERDICT command-exit 0. (2) pnpm --filter @objectstack/service-automation typecheck: exit 0, 'check:test-typecheck: OK'. (3) pnpm --filter @objectstack/spec typecheck: exit 0. spec vitest run --project local --maxWorkers=2: 'Test Files 701 passed (701)', 'Tests 21312 passed | 1 todo', run at 0663fe26c; the merge moved no file under packages/spec. (4) pnpm --filter @objectstack/spec check:generated: 'All 14 generated artifacts are up to date' (spec built after the change). (5) New pin src/runas-user-email-owner.integration.test.ts: 8 passed. Before-leg, the 3 producer files restored to c3954057f blobs (hash-checked) with the pin at head: 'Tests 3 failed | 5 passed (8)'. The card's reproduction, the doors' forwarded shape and the decider's row each failed 'update_record(qa_owner_task) failed: Record ID not found in qa_owner_task'. (6) Ablation 1, the seed line deleted via scripts/ablation-replace.mjs (anchor 1 -> 0, blob changed): 'Tests 7 failed | 59 passed (66)' across the new pin, resume-decider-identity and crud-runas. Restored: blob == HEAD 0e77233a0aab, git diff HEAD empty. Ablation 2, 'delete switched.email;' deleted: 'Tests 6 failed | 23 passed (29)', including the real-stack leak 'expected true to be false' (the decider wrote the submitter's row). Restored the same way. Subjects resolve via relative imports to src/, so no build or dist preflight applies. (7) Reverse type check: AutomationContext literal with email: 42 planted gives 'TS2322: Type 'number' is not assignable to type 'string'' (the rebuilt .d.ts declares the member); restored to the HEAD blob.", "gates": "Local: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 90 commands, the same 90 as the seat's list. On final head 5318e5810 all 90 exit 0, codes captured before any pipe. --ran: 'Run reconciliation — 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN', exit 0. An earlier run at 0663fe26c had pnpm check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET, no dist/ in ~40 packages). It exited 0 once the dists existed; check:type-check-debt's own re-measure built the closure in that run. Local-only family: pnpm check:dev-prereqs exit 0 (diff touches packages/spec). node scripts/check-nul-bytes.mjs exit 0. No exported symbol renamed or deleted, so check-platform-checklist --changed-from does not apply. test:repo not run locally: the diff edits no repo-project test, and Test Core runs it. CI on PR 24636 head 5318e5810, read once at open: 'No other open PR may claim the same issue' success, 'Packed-tarball smoke (opt-in)' skipped. Lint & Repo Gates lanes, Type Check lanes, Check Changeset, Governed Surface Queue Guard, 'The card this PR closes must claim this branch' and the rest were in_progress. Test Core and Build Core were not yet listed.", "mcp_calls": "0", "api_writes": "2 relay strokes (fleet-write, as objectstack-fleet[bot]). (a) pr_create, request fw-20261019T185424Z-8334f7, run 39306460070: POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/24636/assignees ['marchtian']; read back 9217 bytes sent = 9217 stored, assignee marchtian; no labels (a changeset is present, so no skip-changeset). (b) this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/24613/comments. git push x4 to the branch (not REST).", "open_questions": [ { "question": "The flow CEL current_user binds no email (ruling #19939 Q2 A: id, positions, organization, platform-admin flag). A runAs:'user' run's context now holds the user's email. Should runUserOf bind it? This PR does not.", "options": [ "A: keep it unbound (this PR). The email serves the data context's row policies only, and is pinned in flow-cel-current-user.test.ts.", "B: bind it from the run context. current_user.email then resolves on runAs:'user' runs and stays absent on runAs:'system' runs, where no email is seeded.", "C: bind it on both postures by resolving the email for system runs too: a new resolution per system run." ], "recommendation": "A. Real business need, measured: no flow in examples/ or packages/*/src reads current_user.email in CEL; the showcase reads it only in RLS and one page visibleWhen. Long-term soundness: B gives current_user a different shape per runAs posture; C adds a resolution to every system run for a fact nobody reads. Preventing AI mistakes: under A a CEL read of current_user.email fails loudly on every posture; under B it works on user runs and fails on system runs, which is easy to get wrong. Startup focus: no pull, so no widening. Re-ruling Q2 A is the maintainer's call." } ], "out_of_scope_findings": [ "class: a · reach: named producer, measured on the real stack. The approvals status mirror's hand-built { isSystem: true, userId } write (plugin-approvals) and a runAs:'system' flow's data writes ({ isSystem: true, userId, tenantId }) fire a runAs:'user' hook whose context carries no email · evidence: ScopedContext[SEED_USER_GRANTS] (packages/objectql/src/engine.ts) seeds positions, permissions and org_user_ids, never grants.email. A temporary uncommitted probe (ObjectKernel, ObjectQLPlugin, driver-sql :memory:, identity objects, SecurityPlugin plus a set with select 'assignee == current_user.email'; a runAs:'user' afterInsert hook reading the object through ctx.api) printed 'request-fired hook saw [\"DR9r8a-eftpJS8NF\"] mirror-fired hook saw []'. The hook's read context was {email: 'me@example.test', userId} when fired from the request-resolved context and {userId} when fired from { isSystem: true, userId, tenantId }. It is the email twin of #24497 (closed) and the card's optional third acceptance bullet; for the domain:engine lane. A hook fired by a runAs:'user' flow's write now inherits the email through that write's context · dedupe words: SEED_USER_GRANTS email; runAs user hook current_user.email; hook seed grants email RLS; approvals mirror hook email policy", "carrier: none · noted, not filed. No trigger door forwards the request's email into AutomationContext (buildAutomationContext and dispatchFlowAction in @objectstack/runtime pick positions, permissions, tenantId and locale). Nothing needs it, because run setup seeds the stored email for every user-mode run. A session email that differs from sys_user.email would differ between request and run. Read-only, not probed." ], "files_changed": [ ".changeset/24613-run-identity-email.md", "packages/spec/src/contracts/automation-service.ts", "packages/services/service-automation/src/plugin.ts", "packages/services/service-automation/src/engine.ts", "packages/services/service-automation/src/runtime-identity.ts", "packages/services/service-automation/src/runas-user-email-owner.integration.test.ts", "packages/services/service-automation/src/builtin/crud-runas.test.ts", "packages/services/service-automation/src/resume-decider-identity.integration.test.ts", "packages/services/service-automation/src/flow-cel-current-user.test.ts" ], "line_budget": { "additions": 527, "deletions": 41, "against": "merge base 973917c57" }, "deviations": [ "No narrowing: Clause-② stays 'yes (widening)'. The changeset grades @objectstack/service-automation minor and @objectstack/spec minor.", "One existing unit pin changed fixture: crud-runas 'resolves nothing when the trigger already carried permissions AND a peer list' now also carries an email and is retitled. Without the email that context resolves once more, for the email alone, by design.", "Two ablations, one per behaviour: the seed, and the decider segment's removal of the submitter's email. The dispatch named one.", "The record-change runs use the record-change trigger's envelope, not the composed trigger: @objectstack/trigger-record-change depends on service-automation, so a devDependency back would be a cycle.", "The before-leg's refusal text is the not-found that by-id writes of an unreadable row answer, not the record-level 403 sentence the card measured on the showcase. Both are the same refusal of a row the member can read.", "The spec suite ran at 0663fe26c, before the merge. The merge moved no packages/spec file, so it was not re-run on 5318e5810.", "I stopped my own first spec-suite run (its process group, PGID 11147) because 'pnpm test -- --maxWorkers=2' would have dropped the worker cap. It was re-run as 'exec vitest run --project local --maxWorkers=2'.", "Attribution: commits carry AGENTS.md's model-free trailer pair, and the PR body ends with AGENTS.md's session-URL footer, instead of the harness reminder's model-bearing trailer and emoji footer (AGENTS.md takes precedence)." ], "spec_lane_files": [ "packages/spec/src/contracts/automation-service.ts: AutomationContext.email?: string, with TSDoc. It regenerates no artefact: pnpm --filter @objectstack/spec check:generated reports all 14 generated artefacts up to date; api-surface, docs and authorable-surface are unchanged." ], "measurement": "Real stack, the new pin (ObjectKernel, ObjectQLPlugin, driver-sql better-sqlite3 :memory:, identity objects, real SecurityPlugin plus a set with select and update on 'assignee == current_user.email', real AutomationEngine and CRUD nodes). The card's reproduction: a member writes a row assigned to their email through their own request, and a runAs:'user' record_change flow started with the trigger's envelope { record, object, event, userId, tenantId, params } reads the row and stamps notes = current_user.id. BEFORE (producer files at c3954057f): run failed, 'Node 'stamp' failed: update_record(qa_owner_task) failed: Record ID not found in qa_owner_task', notes stayed null. Control: the member's own request (email as resolveAuthzContext seeds it) reads and updates the row, and the same request without the email does not see it. AFTER (5318e5810): run success, the read returns the row, notes equals the member's id. Also after: the doors' forwarded shape passes; a row assigned to another email is still refused; after an approval decision the decider's row is stamped with the decider and the submitter's row is refused; a runAs:'system' run is unchanged.", "ablation": "1) Seed dropped (engine.ts 'if (needsEmail && grants.email) runContext.email = grants.email;' deleted): 7 red, 'Tests 7 failed | 59 passed (66)'. 2) Decider removal dropped ('delete switched.email;' deleted): 6 red, 'Tests 6 failed | 23 passed (29)', including the real-stack leak 'expected true to be false'. Both went through scripts/ablation-replace.mjs with the mutation committed first, each restored to blob 0e77233a0aab == HEAD with git diff HEAD empty. Subjects are src/ via relative imports: no dist, no rebuild leg." }objectstack-fleet commentedon Oct 19, 2026
ACCEPT → PR #24636 comment
6275578010·domain:servicesseat 1 (#6021) ·session_01Nk17Z26X5DB59iQztDBADH· 2026-10-19T19:33Z. Head5318e58101. Contract review PASS6275514585atCONTRACT_REVIEW_TIER. Every check on the head is green or a roster skip. It lands through the merge queue, andFixes #24613closes this card at the merge. The dev's open question is answered A, under ruling #19939 Q2 A: the flow CELcurrent_userstays unbound toemail. The hook-side twin is filed as #24639.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Landed ·
domain:servicesseat 1 (#6021) ·marchtian·session_01Nk17Z26X5DB59iQztDBADH· 2026-10-19T20:07Z. PR #24636 merged through the queue as14ccb1da9at 2026-10-19T20:05Z.Fixes #24613closed this card ascompleted.main:git merge-base --is-ancestor 14ccb1da9 origin/mainexits 0, and the control973917c57exits 0. The PR head5318e58101exits 1, as a squash merge should. All 9 paths are blob-equal to the PR head.pm:dispatchedis removed and the card is unassigned (marchtian). The read-back matched.runAs:'user'run carries its user's stored email, so acurrent_user.emailrow policy admits the run where it admits that user's own request.AutomationContext.emailis declared in@objectstack/spec/contracts.6275514585, ACCEPT6275578010, spec-lane declaration6274926251.domain:enginelane.runAs:'system'flow's data context carries emptypositions/permissionsbeside the user id, so arunAs:'user'hook its writes fire runs on the member baseline instead of that user's own grants #24614 (resolveRunDataContext's system arm) was serialised behind this card and is now free to be claimed.Generated by Claude Code