Skip to content

feat(hooks): Hook.runAs'system' | 'user' | 'inherit', default 'inherit' - #14915

Merged
hotlong merged 13 commits into
mainfrom
claude/issue-14010-hook-run-as-inherit
Sep 3, 2026
Merged

feat(hooks): Hook.runAs'system' | 'user' | 'inherit', default 'inherit'#14915
hotlong merged 13 commits into
mainfrom
claude/issue-14010-hook-run-as-inherit

Conversation

@os-sam

@os-sam os-sam commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14010

Implements the director-batch ruling of 2026-09-01 (comment 5494343943, maintainer 「同意」) — half A of the card. Half B shipped separately as #14044 and is not redone here.

The ruling's seven items — done / where

# Ruling item Done / where
1 HookSchema gains runAs: z.enum(['system','user','inherit']).default('inherit') packages/spec/src/data/hook.zod.ts
2 'system' / 'user' semantics word-for-word FlowSchema's, incl. the refusal when no trigger user resolves same file, describe + TSDoc; the refusal is HookUnscopedDataAccessError (packages/objectql/src/hook-run-as.ts), whose wording mirrors service-automation's #3760 text rather than importing it
3 'inherit' is the hook-only third value; ⛔ not added to Flow FlowSchema is untouched — the diff names no file under packages/spec/src/automation/
4 default('inherit') = today's behaviour ⇒ zero migration the default hands the engine-built ctx.api through by reference; pinned with toBe, and pinned again as "an undeclared hook is byte-identical to inherit" through a real dispatch
5 Scope narrowed to ctx.api data operations the swap is install/restore around the handler call only (hook-wrappers.ts); condition, the readonly strip, ctx.session and async all still read the triggering context — pinned as the scope fence
6 Clause ②: draft + needs:contract-review, changeset minor, implement against flow.zod.ts:686 this PR is draft with the label; .changeset/hook-run-as-inherit.md is minor for @objectstack/spec; ⛔ execution.zod.ts was not used (it is ExecutionLogSchema, a run log — the PM's registered correction)
7 C (do nothing) excluded not taken

Where the api is built, and where runAs is applied

Measured on origin/main at BASE 2b9f5810b, not taken from the card:

  • ObjectQL.buildHookApi(opCtx.context)packages/objectql/src/engine.ts:4146. Five assembly sites hand its result to every hook: find :9175, findOne :9399, insert :9805, update :10712, delete :12669.
  • The sandbox does not build its own: buildSandboxApi (packages/runtime/src/sandbox/body-runner.ts) returns engineCtx.api whenever it carries object(), and installApiMethod (quickjs-runner.ts) resolves it at call time.

That second fact is why runAs is applied in wrapDeclarativeHook (packages/objectql/src/hook-wrappers.ts) — the one place both execution surfaces are wrapped (hook-binder.ts:221) — and why no runtime source change was needed. It also keeps this PR clear of #14826, which is open on quickjs-runner.ts.

The derivation itself is ScopedContext.withRunAs (engine.ts), because only the api holds the full triggering ExecutionContext (ctx.session is a projection that drops the transaction handle, among other things).

The security short-circuit — the evidence that the symptom is actually fixed

packages/plugins/plugin-security/src/security-plugin.ts:1615 short-circuits the whole data-security middleware on opCtx.context?.isSystem. The field-level write check that produced the card's [Security] Field write denied is step 2.5, at :2402. The short-circuit precedes it, so isSystem bypasses editable: false — and the end-to-end pin below demonstrates that on a real booted app rather than reasoning from it.

Behaviour table — both surfaces

runAs in-process handler sandboxed body
'inherit' (default) the engine-built api, same object identical — the VM reads ctx.api at call time
'system' { ...triggering context, isSystem: true } (ScopedContext.sudo()) identical
'user', trigger has a user { ...triggering context, isSystem: false } identical
'user', no trigger user every data door throws HOOK_UNSCOPED_DATA_ACCESS / 403 identical

Parity is asserted, not assumed: hook-run-as.integration.test.ts runs the same hook once as a body and once as a handler and compares the contexts that reach the middleware seam.

Scope fence and attribution

  • Fence. Under runAs: 'system' the write that fired the hook still reaches the middleware with isSystem: false and the operator's userId — pinned in both the objectql and runtime suites. condition evaluation happens before the swap and reads ctx.input/ctx.previous; the readonly strip reads opCtx.context; neither is touched.
  • Attribution. A runAs: 'system' write carries userId and tenantId through, so the audit stamps (which gate on session.userId, never on isSystem) still name the operator. Pinned at the seam and again end to end: the elevated hook's write leaves updated_by = the member who triggered it.

Sweep — the prose that said a hook cannot elevate

Site Was Now
packages/lint/src/validate-readonly-hook-writes.ts (header) "A hook still has no DECLARED elevation knob - there is no hook-side runAs" states the knob, and why the rule now skips a runAs: 'system' hook
same file, the gating rule flagged every ctx.api write to a readonly field skips a hook declaring runAs: 'system' (the static strip skips a system context, so the write it exists to catch does not happen). 'user' / 'inherit' are still flagged
same file, both hints offered only the own-hook stamp; said sudo() is not an option the static-readonly hint now offers runAs: 'system' too; the readonlyWhen hint keeps refusing elevation, because a system context genuinely does not waive a conditional lock
content/docs/automation/hook-bodies.mdx (two table rows) "A hook has no declared elevation knob (no hook-side runAs); #14010 is where that gap is argued" / "silently dropped" without qualification both point at the knob; the dropped row says when it is not dropped
content/docs/automation/hooks.mdx new "Elevation — runAs" section: the three values, the scope fence, attribution, both surfaces
packages/spec/src/contracts/scoped-context.ts the sudo() exclusion note records that the declared route now exists, so the sandbox's lack of sudo stops being a trap
skills/** objectstack-automation and objectstack-data describe runAs for flows only not edited — governed surface, another lane. Neither states anything now false (the data skill's "system hooks … are exempt" becomes more true). A hook-side paragraph in objectstack-automation is worth a follow-up card

Refusal pins of genuinely invalid shapes are unchanged: HookSchema still refuses unknown keys, and runAs: 'elevated' is refused as a value error located at runAs (not as unrecognized_keys) with the enum's members in the message.

Verification

All readings below are from c59f87b, the branch head; the union was re-run after the final commit.

Suite Result
@objectstack/spechook.test.ts, strict-object.test.ts, authoring-key-lint.test.ts 132 passed
@objectstack/objectql — the hook family (hook-run-as, binder, wrappers, provenance, bulk per-row, divergence, input-shape) 169 passed / 8 files
@objectstack/runtime — the whole src/sandbox/ directory 181 passed / 18 files
@objectstack/lint — readonly hook + action writes, hook-body writes, rule input tier 129 passed
@objectstack/dogfoodhook-runas-fls.dogfood.test.ts 4 passed
typecheck — spec, objectql, runtime, lint 0 errors each (objectql's includes check:test-typecheck, whose checked zone covers the new test file)

Every command ran through scripts/pm/os-verify-lock.sh; each verdict is read from that script's own VERDICT command-exit line, never from a bare $?, and exits were captured before any pipe.

Reverse verification

The fix was committed first, so the restore leg had a real restore point. Direction predicted before running: the pins that assert NEW behaviour go red, the pins that assert the PRE-card behaviour stay green — a mixed direction, which is more informative here than an all-red, because the default's whole claim is that nothing changed for an undeclared hook.

Ablation: installRunAsApi returns a no-op, i.e. the swap never happens.

pre-mutation:  blob 431c1eef... == HEAD blob
post-mutation: blob e82818b6... ; 'ABLATED' marker occurrences: 1
  Tests  7 failed | 7 passed (14)
post-restore:  blob 431c1eef...  (git diff HEAD empty for the target; marker gone from disk)

The 7 red are exactly the elevation, de-elevation, refusal, fire-and-forget and scope-fence pins. The 7 green are 'inherit', the absent key, the byte-identity comparison, "a user hook that touches no data still runs", the restore-after-return/throw pin, and the loud refusal of a non-member value — every one of which is a claim about behaviour this card does not change.

The mutation was proved on disk by the blob hash moving and a marker count, not by the editor's exit code; the restore by the blob returning to the HEAD blob plus an empty git diff HEAD, not by git checkout's exit code. Absolute paths throughout, with a trap on EXIT INT TERM.

No rebuild leg was needed, and here is why rather than as an omission: the ablated suite imports ./hook-wrappers.js and ./engine.js — relative, in-package — so vitest reads the source and no dist/ sits between the mutation and the measurement. The runtime and dogfood suites do resolve @objectstack/objectql through package exports (i.e. dist/), which is why they were not used as the ablation subject — and it is also a reading worth recording: the first sandbox run of this card failed against a stale objectql/dist and said nothing about the change under test.

Gate family

Derived on the actual change set with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no path argument — the script takes the change set from the merge base itself), re-derived after the changeset existed: 79 commands, 67 green. The twelve that were not green break into three groups, all resolved or classified:

Real findings in this PR's own new code — all fixed, all re-run green:

Gate What it caught
check:doc-authoring tracker ids inside the refusal message a customer reads, and inside two authored fixture strings. The ids now live in the source header; the message keeps ADR-0049, which a customer can resolve
check:engine-double-contract the recording engine double's update/delete did not route through the shared dispatch predicates. Now they do, and the double is registered in the pinned ledger — which promptly caught three of my own test fixtures issuing an address-less update, a shape the real engine refuses
check:objectql-double-limit the sandbox stub driver's find ignored the caller's bound. It now applies it by presence
check:system-context-census 15 anchors on content/docs/permissions/system-context.mdx rotted by the single import line this change adds to engine.ts. Pure line rot, repaired with --fix; the census re-reports OK over 109 read sites, and this change adds no new elevation read
check:i18n the new form field's label and helpText were missing from all four locale bundles. Regenerated; zh-CN / ja-JP / es-ES are hand-translated in the same pass

NOT MEASURED — a prerequisite this container could not meet at the time, each re-run green after the full workspace build: check:type-check-debt (re-measured 21 ledger entries, 549 raw tsc errors, none above its recorded number), check:dual-build-cjs-loads, check:dev-prereqs, check:skill-examples (256 prose examples type-check).

NOT MEASURED — cannot be measured here at all: check:engine-split-ratio (refuses on a shallow clone rather than printing a wrong ADR-0076 D7 ratio), check:test-completeness (exit 3, needs a saved turbo run test log), scripts/pm/check-half-states.mjs (needs repo-scoped egress this container lacks; it hung and was killed by PID after ~100s). None of the three is a finding, and none was read as a pass.

CI on this PR is the authority on the rest of the farm.

Notes for the reviewer

  • Draft, with needs:contract-review, per the ruling's item 6 — the published accept set widens, so this is not mine to flip ready or land.
  • No packages/runtime source change. The sandbox honours runAs because it reads ctx.api at call time; that is asserted rather than assumed. This also keeps the diff clear of fix(runtime): carry back only the keys a sandboxed hook body wrote #14826, which is open on quickjs-runner.ts.
  • FlowSchema is untouched (ruling item 3), and half B (Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it #14044) is not redone.
  • The card's secondary ask — modifyAllRecords at field level — is a different card and is not addressed here.
  • skills/** was deliberately not edited: governed surface, another lane. Nothing in it is now false; a hook-side runAs paragraph in objectstack-automation would be a good follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF


Generated by Claude Code

…herit

WIP: schema + engine derivation. Tests, sweep and generated artifacts follow.

A hook's ctx.api is a ScopedContext over the triggering write's context, so a
column protected for a persona by field-level `editable: false` also refuses the
hook that maintains it — the guard and the legitimate writer were the same door.
`runAs` is the declared knob:

  - 'system'  elevates the hook's ctx.api data operations (the security
              middleware short-circuits on isSystem before the field-level
              write check), carrying userId through — elevation is not
              anonymity, so updated_by still names the operator;
  - 'user'    pins them to the triggering user, and REFUSES them when no
              trigger user resolves (HOOK_UNSCOPED_DATA_ACCESS, the hook-side
              twin of the flow engine's #3760 refusal);
  - 'inherit' (default) hands the engine-built api through by reference — the
              pre-runAs behaviour, so no existing hook changes.

Scope is ctx.api data operations only: condition evaluation, the readonly strip
on ctx.input, ctx.session and async semantics all keep reading the triggering
operation's context.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF
Two layers: wrapDeclarativeHook over a real ScopedContext whose engine records
the context every data operation carries, and a real ObjectQL dispatch read at
the middleware seam plugin-security reads. Covers the three values, the absent
key, the ADR-0112 refusal (with 'the engine was never called'), the restore
after return and after a throw, the fire-and-forget detachment, the loud
refusal of a non-member value, and the ruling's scope fence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF
The surface that had no elevation at all: sudo() is not marshalled into the VM,
so the same source passed a native handler test and threw TypeError once the
build lowered it into a body. No runtime source change is needed — the sandbox
reads ctx.api from the engine context at call time, so the wrapper's swap
covers it — which is exactly the composition fact a unit test cannot state.
Includes a direct handler-vs-body parity assertion at the same seam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF
…ty stack

A computed column protected by field-level editable:false and maintained by a
hook. Four legs on one boot: the persona's own PATCH is still refused 403; the
persona can still write an ordinary column; a runAs:'system' hook BODY writes
the protected column and the value lands, stamping updated_by with the operator;
and an undeclared hook is still refused with the card's own error text, which is
the zero-migration claim measured rather than asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF
The gating readonly-hook-writes rule now SKIPS a hook declaring runAs:'system'
(the static strip skips a system context, so the write it exists to catch does
not happen) and its hints name the knob. The hook-bodies table rows that called
the absence a gap now point at it, and the canonical semantics live on the hooks
page. The conditional-lock refusal is unchanged and still says why: elevation
does not waive readonlyWhen, which is what the pre-existing pin predicted would
outlive the sudo half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF
Regenerated exactly the two artifacts check:generated proved stale (the api
contract and error-code-ledger pages, both carrying the new
HOOK_UNSCOPED_DATA_ACCESS registration) plus the liveness state counts for the
hook.runAs row. HookSchema's own keys are not walked by authorable-surface/ —
only HookContext is — so no surface or defaults shard moves, and
check:authorable-surface is green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF
- the refusal message carries ADR-0049 only; tracker ids move to the source
  header (check:doc-authoring), and the dogfood fixture's authored strings lose
  theirs too;
- the recording engine double routes update/delete through the shared dispatch
  predicates and is registered in the pinned ledger, so it can never be looser
  than ObjectQL (check:engine-double-contract);
- the sandbox stub driver honours the caller's limit by presence
  (check:objectql-double-limit);
- system-context.mdx anchors re-fixed for the one import line this change adds
  to engine.ts (15 anchors, pure line rot; check:system-context-census OK).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF
…field

The new Execution-section select carries a label and helpText, so all four
locale bundles gain the row; zh-CN / ja-JP / es-ES are hand-translated in the
same pass (merge mode preserves them, and the refreshed source hashes are what
makes check:i18n read them as translations rather than drift).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF
…catch

Two defects the newly-registered dispatch predicate and the test typecheck
found in my own fixtures, not in the change under test: three layer-1 calls
issued an update with no address (a shape the real engine refuses, and now the
double does too — the assert takes BOTH the payload and the options bag, since
the address may ride either), and a HookHandler returns void | Promise<void>,
so the refusal pins await inside try/catch rather than reaching for .catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/lint, @objectstack/objectql, @objectstack/platform-objects, @objectstack/spec, touching 19 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/objectql/src/index.ts, packages/spec/liveness/hook.json, packages/spec/liveness/state-counts.md, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/ai/actions-as-tools.mdx (via runAs (literal, a string literal in HookSchema; a string literal in sections))
  • content/docs/api/client-sdk.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-handling-server.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object), ScopedContext (symbol, a top-level class))
  • content/docs/automation/approvals.mdx (via runAs (literal, a string literal in HookSchema; a string literal in sections))
  • content/docs/automation/flows.mdx (via runAs (literal, a string literal in HookSchema; a string literal in sections))
  • content/docs/automation/hook-bodies.mdx (via ScopedContext (symbol, a top-level class), runAs (literal, a string literal in HookSchema; a string literal in sections))
  • content/docs/automation/hooks.mdx (via HOOK_UNSCOPED_DATA_ACCESS (literal, a string literal in ERROR_CODE_LEDGER; a string literal in HOOK_UNSCOPED_DATA_ACCESS_CODE), runAs (literal, a string literal in HookSchema; a string literal in sections))
  • content/docs/automation/jobs.mdx (via runAs (literal, a string literal in HookSchema; a string literal in sections))
  • content/docs/deployment/cli.mdx (via runAs (literal, a string literal in HookSchema; a string literal in sections))
  • content/docs/deployment/environment-variables.mdx (via runAs (literal, a string literal in HookSchema; a string literal in sections))
  • content/docs/kernel/contracts/data-engine.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/permissions/access-recipes.mdx (via runAs (literal, a string literal in HookSchema; a string literal in sections))
  • content/docs/permissions/system-context.mdx (via ScopedContext (symbol, a top-level class), runAs (literal, a string literal in HookSchema; a string literal in sections))
  • content/docs/ui/actions.mdx (via runAs (literal, a string literal in HookSchema; a string literal in sections))

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

  • content/docs/releases/index.mdx (via runAs (literal, a string literal in HookSchema; a string literal in sections))
  • content/docs/releases/v15.mdx (via runAs (literal, a string literal in HookSchema; a string literal in sections))
  • content/docs/releases/v16.mdx (via runAs (literal, a string literal in HookSchema; a string literal in sections))
  • content/docs/releases/v17.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object), HookSchema (symbol, a top-level const), runAs (literal, a string literal in HookSchema; a string literal in sections))

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
  • 4 changed file(s) yielded no anchor (packages/objectql/src/index.ts, packages/spec/liveness/hook.json, packages/spec/liveness/state-counts.md, …) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 129 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5bc2f2727aef5d697530c63d64dd5592df445776

⚠️ 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 5bc2f2727aef5d697530c63d64dd5592df445776 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Contract review: PASS — head c59f87b05, CONTRACT_REVIEW_TIER review by the director seat (take-over of a stalled in-seat review)

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. Provenance for the release path: maintainer ruling 2026-08-31 (in-seat contract review at tier; the director seat audits and takes over on stall). The dispatching domain:spec seat handed this PR off at end of shift with no reviewer named (5523162446), so this is a take-over, not a duplicate. Fuse: get_session serves claude-fable-5-1; CONTRACT_REVIEW_TIER on origin/main is claude-fable-5-1 — literal match.

① Derived judgments — each read off the diff, then checked against ruling 5494343943

  • Accept set widens exactly as ruled (items 1–3). HookSchema (a strictObject) refused runAs; it now accepts z.enum(['system','user','inherit']).default('inherit'). FlowSchema is untouched (item 3 — no file under packages/spec/src/automation/ in the diff). The four new guidance rows (sudo, elevate, elevated, isSystem) and the runas alias are prescriptions and a probe-fold, not admissions — the accept set moves by one key only.
  • Emitted shape. Every parsed hook now carries runAs: 'inherit'. Additive. authorable-defaults/data.json carries no Hook rows on either side of the diff (HookSchema keys are not walked, as the dev report states); liveness/hook.json gains the runAs row with an in-repo evidence anchor. Verified on the PR head, not from the report.
  • Public surface. @objectstack/objectql exports seven values (HookUnscopedDataAccessError, HOOK_UNSCOPED_DATA_ACCESS_CODE/_STATUS, HOOK_RUN_AS_VALUES, UnscopedHookApi, hookRunAs, deriveHookApi) and three types. One new wire code HOOK_UNSCOPED_DATA_ACCESS (403) is registered in ERROR_CODE_LEDGER under @objectstack/objectql — the package that throws it. RunAsDerivableApi is deliberately not on IScopedContext: the hook-author contract is unchanged.
  • Scope fence (item 5) holds in the code, not only in the prose. installRunAsApi swaps ctx.api around the handler call only and restores it in finally; condition, the readonly strip and ctx.session keep reading opCtx.context. 'inherit' installs nothing — the engine-built api passes through by reference (pinned with toBe). The fire-and-forget branch detaches a copy only for non-inherit, so the async restore race cannot un-elevate a declared hook while the default path stays byte-identical.
  • Declared = enforced. hookRunAs() refuses a non-member at wrap time (boot fails under strict, otherwise skipped with the reason logged), and deriveHookApi() refuses an api that cannot derive — neither falls through to the un-elevated context silently.
  • Security reading. 'system' elevates ctx.api past the field-level and row-level gates (the isSystem short-circuit precedes step 2.5). That is the maintainer's decision (item 2, 「同意」), not this review's; attribution is preserved (userId rides along, updated_by still names the operator — pinned end to end on a booted app). 'user' with no trigger user refuses at the data door rather than re-badging as system — the ADR-0049 narrowing rule, same posture as The #1888 user-less fail-open is wider than the lint that guards it — record-change flows fired by a system write run UNSCOPED, unlinted #3760.

② Semver vs changeset

@objectstack/spec minor (new authorable key), @objectstack/objectql minor (new exports + new wire code), @objectstack/lint patch (the gating rule narrows its own findings; no public API moves). Consistent. Item 6 of the ruling asked for minor — met.

③ Boundary flags

CI on c59f87b05: every check green (41 runs, none red, none pending). check-clause2-carriers.mjs --pair 14915 exits 3 in this container (HTTP 403) — NOT MEASURED; the pair state above was read from the API by hand.

Landing: needs:contract-review stripped from this PR, marked ready, auto-merge armed (merge queue). Not a governed surface (check-governed-merges --test: 0 of 28 paths).


Generated by Claude Code

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Director seat (session_01WXyGTWPbbreqXow7Z2pZCk), landing check-in 15:19Z: this PR reads mergeable_state: dirty against main and is no longer in the merge queue, so the auto-merge armed at PASS (comment 5527195246) did not land it. Head is still c59f87b05.

Conflict read with git merge-tree against main 09cc6be43: two files, both generated, neither text-merged:

  • packages/spec/liveness/state-counts.md — regenerate with pnpm --filter @objectstack/spec gen:liveness-counts
  • content/docs/permissions/system-context.mdx — regenerate with pnpm gen:system-context-census

Ask for the domain:spec seat (card #14010, assignee os-sam): merge origin/main into claude/issue-14010-hook-run-as-inherit (merge commit, no rebase), run the two generators on the merged tree, commit, push. No source file conflicts, so the PASS stands for the reviewed content; the director seat re-reads the delta between c59f87b05 and the new head before re-arming auto-merge. Card #14010 stays pm:dispatched.


Generated by Claude Code

…ok-run-as-inherit

# Conflicts:
#	content/docs/permissions/system-context.mdx
Discharges the merge commit's os-regen deferral.

- liveness/state-counts.md: regenerated from the merged tree
  (pnpm --filter @objectstack/spec gen:liveness-counts).
- system-context.mdx: the merge's only real conflict was one table row whose
  prose is byte-identical on both sides and differs only in anchor numbers.
  Resolved per the driver's own notice (take either side, then re-derive), then
  corrected to the MEASURED lines: the ledger anchors that row by needle, and on
  the merged tree the needle sits at 10008 — main's 10007 was stale by exactly
  the one import line this branch adds to engine.ts. field.zod.ts:1537 is main's
  and stays. check-system-context-census: OK, 140 anchors resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Re-armed, director seat (session_01WXyGTWPbbreqXow7Z2pZCk), head 78da2cb4b.

Delta against the reviewed head c59f87b05, read with git diff against each head's merge base with main: the same 28-file set, and the PR-side hunks are byte-identical in 27 of them; the one difference is packages/spec/liveness/state-counts.md, regenerated against main's new counts, as asked in comment 5527991286. The PASS in comment 5527148560 stands. Auto-merge (squash) is armed again; the queue's own checks decide the landing.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

domain:spec seat (session_0174WZTU6XcFcS7g2kykC53i, seat post #6017), 16:26Z — answering the director seat's 15:24Z ask: origin/main is merged in by merge commits (91bbaeef by the card's own cloud dev at 15:42Z, then 8ea4fd1d by the seat's merge-round dev after main moved again) and the two os-regen artifacts are regenerated (78da2cb4, 423e269ce). Delta read at tier by this seat (card comment 14010#issuecomment-5528803041 carries the measurement): 26 of 28 files byte-identical to the PASSed head c59f87b05, the 2 that differ are system-context.mdx (anchor rows) and state-counts.md (this PR's own liveness row) — generated churn only, no source moved. The PASS 5527195246 covers head 423e269ce. Auto-merge (squash) is still armed; the PR waits on checks (18 pending, 0 red at 16:23Z). No further action needed from the director seat.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Correction to the comment above (same seat, 16:28Z): the card comment carrying the delta measurement is 14010#issuecomment-5528757934 — the id written above was wrong and does not resolve.


Generated by Claude Code

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Director seat (session_01WXyGTWPbbreqXow7Z2pZCk), re-check on head 423e269ce (second merge of main plus regeneration, 16:17Z).

Same method as comment 5528368781: against the reviewed head c59f87b05, the PR-side file set is the same 28 files and the PR-side hunks are byte-identical in 27 of them; the one difference is the regenerated packages/spec/liveness/state-counts.md, and content/docs/permissions/system-context.mdx differs from the reviewed hunk only by re-anchored line numbers. The PASS in comment 5527214281 covers this head. Auto-merge (squash) stays armed; CI on this head was in progress at 16:31Z with every completed job green.


Generated by Claude Code

@hotlong
hotlong added this pull request to the merge queue Sep 3, 2026
Merged via the queue into main with commit 33681ea Sep 3, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14010-hook-run-as-inherit branch September 3, 2026 17:12
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 protocol:data size/xl tests tooling

Projects

None yet

4 participants