Skip to content

fix(service-automation)!: a runAs:'user' hook fired by a user-triggered runAs:'system' flow's write runs on that user's own grants - #24658

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24614-system-run-user-grants
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24614-system-run-user-grants

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #24614

Clause-②: no (narrowing). A runAs:'user' hook fired by a user-triggered runAs:'system' flow's write now runs on that user's own grants instead of the member baseline. For a user whose grants sit below the baseline, as triage's "both directions" names, the hook's reads narrow. #24519's ruling 6271152775 item 1 declares no arm for its own producer. This seat takes the stricter, narrowing arm until the dev's measurement says whether any principal's grants can sit below the baseline. If none can, the seat re-grades and drops the arm.

What changes

resolveRunDataContext's runAs:'system' arm (packages/services/service-automation/src/runtime-identity.ts) no longer carries positions: [] / permissions: [] beside a named user. This applies ruling 6271152775 (on #24519) to this producer: a context that names a user carries no empty arrays, so the hook seed from PR #24518 resolves that user's own grants in the run's organisation.

  • Named-user system run: { isSystem: true, actor, userId, tenantId?, flowRunId? }. A runAs:'user' hook one of its writes fires de-elevates to this context, finds no permissions, and resolves the user's positions, permission sets and peers through the request path's resolver.
  • Post-decision system segment: after an approval decision the run's user is the decider (ruling 6267719791 Q1 A), so the hooks the post-decision writes fire now run on the decider's grants.
  • Unchanged: isSystem, actor, userId and tenantId exactly as before; the run's own authority (the security middleware's isSystem bypass is its first gate, and nothing ahead of it reads the arrays); a user-less system run (a schedule) keeps positions: [] and permissions: [], and a runAs:'user' hook it fires is still refused for want of a user; the runAs:'user' arm.
  • Published type: RunIdentityContext / RunDataContext declare positions and permissions optional, because the named-user system envelope no longer carries them. The changeset names it.

The engine.ts docblock on the decision segment, which leaned on "a system run's data context carries none", is rewritten to say what the hook now resolves.

Measurement (real stack)

runas-system-user-hook-grants.integration.test.ts, beside runas-user-hook-org-peers.integration.test.ts: ObjectKernel, ObjectQLPlugin, driver-sql on better-sqlite3 :memory:, the identity objects, the real SecurityPlugin and the real AutomationEngine with its CRUD nodes. A runAs:'system' flow triggered by a user writes, and a runAs:'user' hook on the written object reads.

  • Above the baseline: a user holding a set the baseline does not grant. The hook now reads what the user's own request reads; under the old envelope it was refused (PERMISSION_DENIED, 403).
  • Below the baseline: the direction exists and is pinned: a principal whose own grants read fewer rows than the baseline does. The hook now reads exactly what that user's own request reads. The grant detail is in the report to the seat, not here.
  • Decider: a runAs:'system' flow pauses at a service-resumable gate and is resumed with a decider. The submit write's hook runs on the submitter's grants and the post-decision write's hook on the decider's.
  • CONTROL, user-less: a schedule-shaped run keeps positions: [], permissions: [] and no userId, and the hook is refused with HOOK_UNSCOPED_DATA_ACCESS (403).
  • CONTROL, the baseline: the old envelope fired by hand answers the baseline, the point each direction moved from.

The existing pins of the old shape moved with it: crud-runas.test.ts, the system case in runas-grant-resolution.integration.test.ts (its "resolver not consulted" proof is now no grant-table read and no authz fact filled), the two system-envelope assertions in resume-decider-identity.integration.test.ts, and the two runAs:'system' expectations in plugin-approvals' restored-approval-continuation.test.ts (the parked() helper's and the post-decision one), which now expect { userId, isSystem: true } with no permissions key.

Ablation (one run, on committed 59575b2c4)

node scripts/ablation-replace.mjs restored the empty arrays in the system arm (anchor 1 to 0, blob 4817a333 to bc398da1) and ran the new file plus crud-runas.test.ts: 5 failed, 39 passed. The three flow-driven real-stack pins went red: the below-baseline hook read the baseline's rows instead of the user's, the decider test's submit-write hook did the same, and the above-baseline pin failed on its envelope assertion (the write carried permissions). The two named-user shape pins in crud-runas.test.ts went red too. GUARD, the user-less CONTROL and the user-less unit pin stayed green, as predicted. Restored: blob back to 4817a333 (HEAD), git diff HEAD empty, git status --porcelain empty.

Tests and gates (head 0f452e832, after merging origin/main f89d29e38 with os-regen-merge.sh)

  • @objectstack/service-automation: vitest run, 194 files / 2587 tests passed at 0f452e832; typecheck (tsc plus check:test-typecheck) exit 0.
  • @objectstack/plugin-approvals: vitest run against this tree's built service-automation, 83 files / 1132 tests passed, 0 failed (every file loads; an earlier count of 1120 left out two mirror files that could not load before trigger-record-change was built); typecheck exit 0, its test-layer debt ledger holding.
  • Other importers of the changed export, at bf00bb57c: @objectstack/trigger-record-change 114/114, @objectstack/trigger-schedule 174/174, @objectstack/verify's six system-run files 70/70.
  • Derived gate families (dispatch-gates.mjs --commands on the final diff, 70 commands, check:i18n and check:i18n-stale-fill added by the plugin-approvals path): all exit 0, and --ran answered 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN at 0f452e832.

Round 1 (seat rework record 6277856952): the claim's surface took plugin-approvals' restored-approval-continuation.test.ts for its two runAs:'system' expectations only; both now expect no permissions beside the user, and the package's suite passes in full.

Acceptance notes


Generated by Claude Code

…es no positions or permission sets

A runAs:'user' hook fired by such a run's write de-elevates to the run's
data context and keeps carried arrays verbatim, so the empty arrays ran it
on the member baseline. Without them the hook seed resolves the named
user's own grants in the run's organisation. A user-less system run keeps
today's envelope.

Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH
Co-authored-by: Claude <noreply@anthropic.com>
… on the user's own grants, both directions

Real stack: a user granted more than the baseline, a user whose own sets
read fewer rows than it, the decider after an approval decision, and the
user-less system run as the control.

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

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 3 documentable anchor(s).

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

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-6.mdx (via AutomationEngine (symbol, a top-level class))

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

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f89d29e382e7d50cfe1b2a98742ac1c3d436cd26 → packageMentionDocs.

Which tree this was computed on

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

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

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

REWORK · patch round 1 · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-19T21:33Z · head bf00bb57c

This answers the dev's blocked report (6277772125 on #24614). The seat extends the claim's surface (6276277748) by one test file, for two assertions only:

After the edit, run plugin-approvals' suite (the dev measured 2 failed / 1118 passed on these two cases) and the derived list again on the new head (--ran: 0 NOT-MEASURED, 0 UNRUN). The seat then runs the CONTRACT_REVIEW_TIER review of record on that head, since the change is declared a narrowing.


Generated by Claude Code

…carries no permission sets beside its user

The two assertions pinned the system arm's former envelope, which carried
empty `permissions` beside the named user; the arm now carries none.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0f452e832bc477f7991713d0d21b4a0b91d4a434
Local-runs: none
Scope: packages/services/service-automation/src/runtime-identity.ts, packages/services/service-automation/src/engine.ts, packages/services/service-automation/src/builtin/crud-runas.test.ts, packages/services/service-automation/src/runas-grant-resolution.integration.test.ts, packages/services/service-automation/src/resume-decider-identity.integration.test.ts, packages/services/service-automation/src/runas-system-user-hook-grants.integration.test.ts, packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts, .changeset/24614-system-run-user-grants.md + packages/objectql/src/engine.ts (ScopedContext.withRunAs, the SEED_USER_GRANTS seed, buildSession, roles), packages/objectql/src/hook-run-as.ts, packages/plugins/plugin-security/src/security-plugin.ts (the isSystem bypass, isPrincipalLessContext, getReadFilter, every collectRLSPolicies caller, stageRlsMembership), packages/plugins/plugin-security/src/rls-compiler.ts, packages/core/src/security/resolve-authz-context.ts (resolveUserAuthzGrants, grantAppliesInTenant), packages/services/service-automation/src/plugin.ts, packages/services/service-automation/src/index.ts, packages/plugins/plugin-approvals/src/approval-service.ts, packages/triggers/trigger-record-change/src/record-change-trigger.ts, packages/runtime/src/domains/automation.ts, packages/runtime/src/sandbox/body-runner.ts, packages/plugins/plugin-audit/src/comment-access-hooks.ts, packages/spec/src/data/hook.zod.ts, packages/spec/src/contracts/automation-service.ts, packages/spec/src/kernel/execution-context.zod.ts

Read at 2026-10-19T22:33Z, read-only: the net diff against merge base f89d29e38 (8 files, +410 −25), card #24614 with every comment (triage 6272519127, serial note 6272864997, claim 6276277748, dev reports 6277772125 and 6278565512), ruling 6271152775 and the analysis 6270492286 it answers, ruling 6267719791 Q1 A, the disclosure ruling 5323310017, PR #24658's body, comments (rework record 6277856952) and file list, card #24639 with its ordering note 6277888374, and the head's check-runs. Every cited file was read at this head.

① Derived judgments

  1. The system arm's envelope — right. resolveRunDataContext's runAs:'system' arm now spreads positions: [] / permissions: [] only when context.userId is absent. A named-user run hands ObjectQL { isSystem: true, actor, userId, tenantId?, flowRunId? }; a user-less run (a schedule) keeps today's envelope byte for byte. The isSystem, actor, userId and tenantId lines of the arm are untouched by the diff. Pinned by key absence, not toEqual alone (crud-runas.test.ts, three cases: the named user, the named user whose trigger forwarded arrays, the user-less control).

  2. No reader of a system context's arrays loses authority — right, traced. The security middleware's isSystem bypass (security-plugin.ts if (opCtx.context?.isSystem) return next()) is the first statement after the write-epoch bump, which reads no arrays; getReadFilter answers undefined on isSystem; the three capability evaluators short-circuit on isSystem before reading. Every other array read in the plugin spells ?? [] or Array.isArray (isPrincipalLessContext, the CRUD gate, every collectRLSPolicies caller, resolvePermissionSets, the explain engine), so absent reads as []; stageRlsMembership runs only on the de-elevated hook context, after the seed filled it. ObjectQL's buildSession now yields session.positions undefined where it yielded [] on such a write: HookContextSchema.session.positions is optional, and both readers guard (body-runner.ts Array.isArray; comment-access-hooks.ts callerContext prefers the operation's own system context and hands positions to helpers that ?? []). ScopedContext.roles likewise answers undefined; nothing reads .roles off an api. runUserOf guards with Array.isArray, so the flow's current_user.positions is [] as before; seedRunGrants skips runAs:'system'; stampSystemInsertOwner reads only isSystem; the runtime door forwards arrays only when non-empty. The one reader whose answer changes is the one meant to: ScopedContext[SEED_USER_GRANTS]'s needsGrants = !Array.isArray(ctx.permissions).

  3. The runAs:'user' hook resolves the user's own grants — right, and the narrowing is real. withRunAs('user') copies the write context with isSystem: false; the seed finds no permissions and resolves the named user's positions, sets and peers through resolveUserAuthzGrants in the run's tenantId, the resolver the request path uses. Wider for a user holding a set the baseline does not grant (the hook now reads an object it was refused on); narrower where a held set's row policy now applies to the hook's read, which the baseline never applied. Both directions are pinned on the real stack against the user's own request as the oracle, with a GUARD that the two users differ from the baseline in opposite directions and a hand-fired baseline control. The concrete grant detail stays in the seat copy, as ruling 5323310017 requires; this record keeps the same discipline. Two edges, read and judged right: (a) a named-user system run whose trigger carried no tenantId (the record-change door forwards session.organizationId only when present, the runtime door ec.tenantId only when present) resolves the user's organisation-less envelope. grantAppliesInTenant admits only global grant rows there, there is no "every organization" mode, and Layer 0 resolves an organisation-less principal's tenant-scoped reads to nothing under group, so the hook can read at most what that user's own organisation-less request reads. The card's sentence names "a user and its organisation"; the claim's exclusion names "only when a user is named"; the diff follows the claim, and neither reading leaks beyond the user's own envelope, so this stands as a note for the seat, not a defect. (b) A forwarded ADR-0090 agent ceiling was never honoured on this arm (the old envelope replaced it with []); the record-change door forwards no arrays at all; the delegated hook path is outside this card.

  4. The approvals decider path after ruling 6267719791 Q1 A — right. resolveDecisionSegmentContext deletes the trigger user's positions, permissions, peers and email, sets userId to the decider and returns before seedRunGrants on a system run; the arm then hands the post-decision writes exactly Q1 A's { isSystem: true, userId: the decider }, no permission resolution for the run, elevation unchanged. The hook a post-decision write fires resolves the decider's grants, and the submit write's hook the submitter's (the hgp_review pin). The engine.ts docblock that leaned on "a system run's data context carries none" is rewritten to say what the hook now resolves. The approvals status mirror already writes { isSystem: true, userId, tenantId? } with no arrays (PR fix(plugin-approvals): an approval's status write-back runs its automation as the person who acted, in the record's organisation #24634, merged), so the two producers of the family now obey one rule.

  5. plugin-approvals — right, and nothing else moved there. Exactly the two expectations the rework record 6277856952 allowed, the parked() helper's runAs:'system' one and the post-decision one of the runAs:'system' approve/reject it.each, now read { userId, isSystem: true } with no permissions key: one file, +2 −2, the package's only path in the diff.

  6. The published type — right, and declared. RunIdentityContext.positions / permissions become optional; RunDataContext is that type; both are exported from the package entry. A reader-side narrowing of a published type of @objectstack/service-automation (published, 18.0.0-next.5), named in the changeset with the consequence for a reader; TypeScript Type Check is green, so no in-repo reader relied on the required shape.

  7. Unchanged, as excluded by the claim — right. The runAs:'user' arm's code is not in any hunk (the optionality lands on the shared interface, not on that arm); the user-less control keeps positions: [] / permissions: [] with no userId, and the hook it fires is still refused HOOK_UNSCOPED_DATA_ACCESS 403 (pinned); hooks declaring runAs:'system' or 'inherit' are untouched.

② Semver level

.changeset/24614-system-run-user-grants.md matches what the diff publishes: @objectstack/service-automation: minor; a ! title; Clause-②: no (narrowing) in the reader's fixed spelling; the BREAKING banner, "shipped as minor under the launch-window convention for breaking changes", the form 29 sibling changesets at this head carry; the adr-0087 not-required (no-migration-prescription) marker with its why, closing the other categories on facts; and the one-line fix for an author ("declare the hook runAs:'system'"). The PR body carries the same Clause-② line verbatim from the claim. no rather than yes is the truthful value: no export, key, option or field is added, two members may now be absent, and the type change is the written form of that shrink. plugin-approvals' change is test-only and owes no changeset. Check Changeset and Lint & Repo Gates (the adr-0087 and no-major gates) are green on this head.

③ Boundary flags

Implemented-by: claude/issue-24614-system-run-user-grants
Reviewed-by: session_01Nk17Z26X5DB59iQztDBADH

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-19T22:35Z · head 0f452e832b.

The contract review of record is PASS 6278843703 at CONTRACT_REVIEW_TIER on this head. It covers the whole diff on the narrowing face. This seat review records the rest. The PR lands through the merge queue.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants