fix(objectql): elevate ObjectRepository.execute() to REST/MCP's trusted posture - #15051
Conversation
…ed posture Director ruling 决裁批 #24 (2026-09-01), clause 2: the census (repo + examples/ + apps/, production + test) found ZERO real callers of ObjectRepository.execute() anywhere — every hit was prose describing the shape, never an invocation — so the premise (callers are internal / test / single-digit) holds in its strongest form, and the elevation is implemented. ObjectRepository.execute() now hands the action handler a ScopedContext bound to { ...callerContext, isSystem: true } (ctx.api) and the same elevated envelope as ctx.executionContext — the identical sudo()-shaped formula buildActionExecutionContext (REST /actions, MCP run_action) and recomputeSummaries's systemCtx already use. Before this, the handler got neither api nor executionContext: a handler composing a sibling write via ctx.api.object(x).update(y) got ctx.api === undefined, and the sandbox's own last-resort fallback ran that write as a non-system caller, so the engine's static readonly strip applied to this path and not to REST /actions or MCP run_action. Fixes #13866 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Pure line rot: content/docs/permissions/system-context.mdx:183 pointed at engine.ts:14463, which the elevation fix's inserted JSDoc pushed down to 14496 (the ScopedContext.isSystem getter itself is unchanged). Repaired via `node scripts/check-system-context-census.mjs --fix`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
check:objectql-double-limit flagged the fake driver's find() in engine-repo-execute-elevation.test.ts as a new limit-blind ObjectQL double. Apply the caller's bound after the filter, by presence — the gate's own suggested fix — so the baseline stays at zero new limit-blind doubles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…binators check:where-matcher flagged the fake driver's matches() in engine-repo-execute-elevation.test.ts as a new silently-wrong WHERE matcher (a $-prefixed combinator key would be read as a literal field name instead of being rejected). Refuse it loudly instead, matching the exact idiom engine-readonly-strip-caller-values.test.ts's own fake driver already uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 78bf342e86c2043e137e494f986d7cc9dcc9f3c9 && git checkout 78bf342e86c2043e137e494f986d7cc9dcc9f3c9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0fb944b4f6ec7437ee3abb25fcedbde629466347 183cd58eceeaac3f820a08f31f85b7f1bf438e80 && git checkout -B drift-repro 0fb944b4f6ec7437ee3abb25fcedbde629466347 && git merge --no-ff 183cd58eceeaac3f820a08f31f85b7f1bf438e80
node scripts/docs-audit/affected-docs.mjs --json 0fb944b4f6ec7437ee3abb25fcedbde629466347 |
…ass on repo.execute() PR #15051's "what widens" statement named only the static `readonly` strip that isSystem: true skips on ctx.api.object(x).update(y). That is true but materially incomplete: ObjectQL's registered security middleware (security-plugin.ts:1614-1616) reads isSystem as a TOTAL, unconditional bypass, so every find/insert/update/delete through this ctx.api also skips RLS scoping, field-level security, the CRUD/export checks, the ADR-0103 engine-owned write guard, the package-managed/system-row/curated-capability/ audience-anchor write gates, the referential-integrity check and the tenant-audit mute -- matching REST /actions and MCP run_action exactly, and documented in full at content/docs/permissions/system-context.mdx. Bounded on two sides: metadata-plane schema masking (object-schema-fls.ts) is a separate REST/GraphQL dispatch path this ctx.api surface never reaches, and plugin-sharing's insert/update materialisation skip was already retired by the 2026-08-31 ruling on #13533. Rewrites the changeset paragraph and the execute() docstring to agree; the PR body itself is corrected via the GitHub API in the same round (a merged PR's body can still be edited for the historical record). No implementation, census, ablation, gate, or changeset-level change -- prose only, per the review finding on #15051. Part of #13866 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
|
os-dev-report {
"issue": 13866,
"status": "done",
"branch": "claude/issue-13866-disclosure-fix",
"pr": "https://github.com/objectstack-ai/objectstack/pull/15131",
"premise_still_valid": false,
"summary": "Premise mismatch discovered at start: the dispatch described PR #15051 as an open draft carrying needs:contract-review; by the time this round started it was already merged (a54855005, merged by hotlong the maintainer, needs:contract-review no longer on it) and issue #13866 closed. No code change was needed or made -- only the disclosure-completeness fix. Corrected the 'what widens' statement in all three named places: (1) PR #15051's body, updated via the GitHub API for the historical record (a merged PR's body can still be edited) -- read back byte-identical, footer stayed session-URL form (did not downgrade to bare, contrary to the general PATCH-downgrade note); (2) .changeset/repo-execute-elevated-context.md; (3) the execute() docstring in packages/objectql/src/engine.ts. Since the original branch/PR was closed, the changeset+docstring edits landed on a fresh branch off current origin/main and opened as a new draft PR (#15131, not enqueued, not auto-merge-armed) referencing #13866 and #15051. One incidental mechanical fix: the docstring insertion shifted engine.ts line numbers by 26, rotting one system-context.mdx census anchor; repaired with the gate's own --fix (pure line rot, population/classification unchanged: 106 sites/20 packages/45 files).",
"tests": "Built @objectstack/objectql's dependency closure then the package itself (pnpm --filter '@objectstack/objectql^...' build, then --filter '@objectstack/objectql' build), both exit 0 via os-verify-lock. Re-ran the PR's 5 pins (engine-repo-execute-elevation.test.ts) on final head ba771e706: 'Test Files 1 passed (1) / Tests 5 passed (5)'. Package typecheck (tsc --noEmit + tsconfig.scripts.json + check:test-typecheck): exit 0, 'check:test-typecheck: OK'. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths, merge-base derivation), re-derived after the anchor-fix commit: 73 commands on the final 3-file diff (.changeset/repo-execute-elevated-context.md, content/docs/permissions/system-context.mdx, packages/objectql/src/engine.ts) plus the 4 roster gates flagged as 'not evidence either way' since a touched path sits under their scan root (check-changeset-fixed.mjs, check:authz-resolver, check:error-code-casing, check:filter-alias-parity) -- all read separately, all pass. Every command's exit code captured before any pipe (redirect-then-capture). Result: all pass except two PREREQUISITE NOT MET (exit 3 / effectively non-pass, not findings, same shape #15051 itself reported): check:dual-build-cjs-loads (needs every workspace package's dist/) and @objectstack/spec check:skill-examples (needs @objectstack/client-react built, unrelated to this diff). node scripts/pm/check-governed-merges.mjs --test on the final 3 files: NOT governed (0/3 hit GOVERNED_SURFACES). No control bytes in either edited file (grep -naP control-byte scan, exit 1 = no matches).",
"mcp_calls": 10,
"open_questions": [],
"out_of_scope_findings": [],
"isystem_enumeration_reverified_independently": [
{"row": "Security middleware total short-circuit (the bypass gate itself)", "anchor": "security-plugin.ts:1614-1616", "reviewer_cited": "1614-1616", "confirmed": true, "note": "read directly: comment 'System operations bypass security' + `if (opCtx.context?.isSystem) { return next(); }` ahead of every other check in the middleware"},
{"row": "RLS read scoping (getReadFilter)", "anchor": "security-plugin.ts:4344", "reviewer_cited": "4344", "confirmed": true},
{"row": "Field-level security", "anchor": "security-plugin.ts:4495", "reviewer_cited": "4495", "confirmed": true},
{"row": "CRUD permission checks (same middleware, short-circuited by the same return)", "anchor": "security-plugin.ts:1616", "reviewer_cited": "1616", "confirmed": true},
{"row": "Export permission (canExport)", "anchor": "security-plugin.ts:4573", "reviewer_cited": "4573", "confirmed": true},
{"row": "ADR-0103 engine-owned/append-only write guard", "anchor": "system-write-guard.ts:96 (predicate body) and :120 (call site), invoked at security-plugin.ts:1736", "reviewer_cited": "system-write-guard.ts:96,120, called at security-plugin.ts:1736", "confirmed": true, "note": "predicate function starts line 95, its isSystem-reading return statement is line 96; matches reviewer exactly"},
{"row": "package-managed / system-row / curated-capability-name / audience-anchor write gates", "anchor": "security-plugin.ts:1690, 1704, 1716, 1724 (range 1690-1724)", "reviewer_cited": "1690-1722 (approx)", "confirmed": true, "note": "measured range extends 2 lines past the reviewer's citation (audience-anchor gate call is at :1724) -- same 4 gates, corrected endpoint"},
{"row": "Static readonly strip -- UPDATE, single row", "anchor": "engine.ts:11290", "reviewer_cited": "11290", "confirmed": true},
{"row": "Static readonly strip -- UPDATE, bulk/predicate", "anchor": "engine.ts:11473", "reviewer_cited": "11473", "confirmed": true},
{"row": "Static readonly/runtime-owned strip -- INSERT (stripRuntimeOwnedFields, one call strips BOTH autonumber and static-readonly fields together, reported under reason:'readonly')", "anchor": "engine.ts:10025", "reviewer_cited": "10025 (labeled 'autonumber/runtime-owned strip')", "confirmed": true, "note": "confirmed this single site covers both behaviors -- canonical content/docs/permissions/system-context.mdx independently labels the SAME line 'readonly strip bypassed -- INSERT (engine pass)', reconciling the two framings as the same code"},
{"row": "Referential-integrity check (assertReferencesResolve)", "anchor": "engine.ts:5892", "reviewer_cited": "5892", "confirmed": true},
{"row": "Tenant-audit mute", "anchor": "engine.ts:3773", "reviewer_cited": "3773", "confirmed": true},
{"row": "Hook-session isSystem propagation (carries the flag onward -- not itself a gate)", "anchor": "engine.ts:3543 (buildSession)", "reviewer_cited": "3543", "confirmed": true},
{"row": "Audit attribution label ('system' vs 'anonymous' -- not itself a gate)", "anchor": "engine.ts:12014", "reviewer_cited": "12014", "confirmed": true}
],
"not_reached_boundaries_reverified_independently": [
{"claim": "Metadata-plane schema masking (metadata-core/object-schema-fls.ts) is a separate REST/GraphQL schema-serving dispatch path, not the engine write pipeline -- NOT reached via repo.execute()'s ctx.api", "confirmed": true, "method": "read the file header: '@objectstack/metadata-core' depends on {@objectstack/spec, zod} only; its two consumers are @objectstack/rest and @objectstack/runtime's schema-serving exits (GET /meta/object/:name etc.), never the engine's registered middleware chain that ctx.api's find/insert/update/delete/count/aggregate/execute surface drives. Note: isSystem DOES bypass this exemption elsewhere in the platform per content/docs/permissions/system-context.mdx row 7 -- it's simply orthogonal to and unreachable from this PR's specific widening, which is the reviewer's actual (narrower, correct) claim."},
{"claim": "plugin-sharing/rule-hooks.ts's isSystem materialisation skip (insert/update) was already retired by the 2026-08-31 ruling on #13533 -- system and user writes materialize identically today", "confirmed": true, "method": "read rule-hooks.ts directly: block comment at top states 'RETIRED -- the isSystem materialisation skip ... Maintainer ruling 2026-08-31 (verbatim, untranslated)... 裁定:系统写参与逐记录共享物化'; the surviving isSystem check at line 292 is a DIFFERENT skip (afterDelete-only revocation-timing delegation to record-share-cascade.ts, not a silencer, per the file's own comment) -- confirmed content/docs/permissions/system-context.mdx row 30 independently documents the same distinction in the same words"}
],
"census_zero_callers_reverified_independently": {
"confirmed": true,
"method": "grep -rn '\\.execute\\(' across packages/, examples/, apps/, filtered to lines mentioning .object(/ScopedRepo/ObjectRepository -- every hit outside this PR's own new pin test (engine-repo-execute-elevation.test.ts) is prose in a comment describing the shape, never an invocation. Positive control: '.object(x).update(' -> 195 hits, '.object(x).insert(' -> 49 hits, confirming the grep methodology fires when a real caller exists."
},
"final_wording_used_in_all_three_places": {
"pr_15051_body": "Replaced the original 'What widens' paragraph with a corrected version (kept the original nested in a collapsed disclosure block (details/summary tags) for the record) stating the security middleware's total bypass, the full list of skipped behaviours with anchors, the two bounded exclusions, and the still-zero measured blast radius. Verified byte-identical on read-back after the update_pull_request call.",
"changeset_repo_execute_elevated_context_md": "Rewrote the 'What widens' paragraph to lead with the security-middleware total bypass (citing security-plugin.ts:1614-1616), list every skipped behaviour with anchors, then two new paragraphs for the bounded exclusions (object-schema-fls.ts, rule-hooks.ts) and the census zero-callers close.",
"engine_ts_execute_docstring": "Added a new '[Disclosure completeness]' paragraph immediately after the existing census paragraph (left the original text intact apart from removing its now-incomplete parenthetical), stating the same total-bypass fact, the full anchor list, and the two bounded exclusions."
}
}Generated by Claude Code |
Fixes #13866
The ruling (Director seat, 决裁批 #24, 2026-09-01, maintainer verbatim reply 「同意」)
Census (deliverable one)
Method: chained-accessor grep (
.object('<name>').<method>(, same-line AND multiline up to ~120 chars), cross-checked by identifier-categorizing every.execute(hit repo-wide (1115 occurrences), by everyScopedContext/ObjectRepositoryconstruction site, and by the sandbox's own method allowlist.Firing positive control — the identical chained-accessor technique against
ObjectRepository's sibling methods, same repo, same day:.insert(.update(.find(.findOne(.updateById(.count(.execute(Production vs test,
.object(x).execute(y)(any gap, any package,examples/,apps/): 0 production, 0 test.Every one of the 1115
.execute(hits resolves, by identifier, to something else entirely:engine.execute(/driver.execute((raw SQL / flow-engineexecute,packages/objectql/src/engine.ts:13358's differentexecutemethod),automation.execute(/executor.execute((workflow dispatch). The handful of literalObjectRepository.execute()/ScopedRepo.execute()strings in the tree are prose, not invocations —packages/runtime/src/action-execution.ts:459,packages/runtime/src/sandbox/body-runner.ts:896,packages/lint/src/validate-readonly-action-writes.ts:47all describe this method as "the thirdexecuteActioncaller" that "supplies neitherapinorexecutionContext" — independently corroborating this PR's own finding, before this PR existed.Two structural facts explain the zero and rule out an undercount:
execute—packages/runtime/src/sandbox/quickjs-runner.ts'sREAD/WRITEarrays (find/findOne/count/aggregate/insert/update/delete/updateMany/deleteMany/upsert) omit it entirely, so asource: "..."script body (the dominant action-authoring shape — seeexamples/app-showcase/src/ui/actions/index.ts) cannot callctx.api.object(x).execute(y)even if authored to.registerActionhandler found (examples/app-todo/src/actions/task.handlers.ts,service-sms/service-settings/service-storage/plugin-email'ssettings.registerActiontest actions) uses a slimActionContext { record, engine, user, params }shape and never reachesctx.api/.execute()— consistent with them being dispatched through REST/actions/ MCPrun_action(which build exactly that richer shape) and never throughObjectRepository.execute().Verdict: premise HOLDS, in its strongest form (population = 0, not merely single-digit).
Implementation
ObjectRepository.execute()(packages/objectql/src/engine.ts) now builds{ ...this.context, isSystem: true }— the identicalsudo()-shaped elevationbuildActionExecutionContext(REST/actions, MCPrun_action) andrecomputeSummaries'ssystemCtxalready use — and hands the handlerexecutionContext(that envelope) andapi(a realScopedContextbound to it), on top of the existinguserId/tenantId/roles. Before: neither key existed, soctx.api.object(x).execute(y)— the in-process action-composition shapeaction-execution.tsnames this method's own reason to exist — gotctx.api === undefined.What widens —⚠️ corrected post-merge for disclosure completeness, see #15131 and the PR comment below. The paragraph below (as originally written) named only the static-readonly strip; that was true but materially incomplete.
ctx.apiinside arepo.execute()-dispatched action handler carriesisSystem: true, and ObjectQL's registered security middleware reads that as a total, unconditional bypass (plugin-security/src/security-plugin.ts:1614-1616, "System operations bypass security" —return next()ahead of every other gate in that middleware), not only the staticreadonlystrip. Everyfind/insert/update/deletethe handler drives through thisctx.apialso skips, in the same stroke: RLS read scoping (:4344) and field-level security (:4495); the CRUD/export permission checks in the same middleware (:1616,canExportat:4573); the ADR-0103 engine-owned/append-only write guard (system-write-guard.ts:96,120, called at:1736); the package-managed / system-row / curated-capability-name / audience-anchor write gates (:1690-1724); the referential-integrity check (engine.ts:5892) and the tenant-audit mute (engine.ts:3773) — in addition to the static readonly/runtime-owned strip on both UPDATE paths (engine.ts:11290,:11473) and the INSERT path (:10025), not the single call site named below. This matches the platform's own documented posture (content/docs/permissions/system-context.mdx: "Elevation is total, and it is not granular") and REST/actions/ MCPrun_actionalready carry the identical exposure — so this widens an existing bypass to a third dispatch path, not a new one.Bounded on two sides: metadata-plane schema masking (
metadata-core/object-schema-fls.ts:228) is a separate REST/GraphQL dispatch path thisctx.apisurface never reaches; andplugin-sharing/rule-hooks.ts's insert/update materialisation skip was already retired by the maintainer's 2026-08-31 ruling on #13533 (system and user writes materialise sharing grants identically today).Per the census below, the measured blast radius today is still zero — no shipped write changes behaviour — so the widening is total in kind, empty in practice, today.
Original "what widens" paragraph (superseded by the correction above, kept for the record)
a
readonly: truefield a handler writes throughctx.api.object(x).update(y), reached viarepo.execute(), now lands instead of being silently stripped by the engine's static-readonly strip (!opCtx.context?.isSystem,packages/objectql/src/validation/rule-validator.ts) — matching what the identical write already does through REST/actionsand MCPrun_action. Per the census above, no shipped write exercises this path today, so nothing that ships changes behaviour; this widens what a future caller of this method is accepted to do.Ablation (pin:
packages/objectql/src/engine-repo-execute-elevation.test.ts, 5 cases)Predicted in writing before mutating: all 5 cases go RED — each drives a handler through
ctx.api.object(x).<op>or readsctx.executionContext.<field>, bothundefinedpre-fix, throwingTypeError: Cannot read properties of undefined.packages/objectql/src/engine.tsback to the pre-fix 3-line body (verified the exact 12-line replacement matched exactly once before writing — a 0-hit replace would have exited the same way).pnpm --filter @objectstack/objectql build, exit 0 — still valid TS) and ran the pin: 5 failed (5), every failure exactly the predictedTypeError,os-verify-lockVERDICTcommand-exit 1.git checkout HEAD -- packages/objectql/src/engine.ts(never barecheckout --). Proved the restore by state:git diff HEADempty,git status --porcelainempty, andgit hash-objecton the working-tree file byte-identical togit rev-parse HEAD:<path>(1a476fb09c20ce9e6bcadd570812b337688bf961both sides).command-exit 0.Gate verdicts
Derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths — merge-base derivation), re-derived on the final file list after every commit and again on the final commit. Final derivation: 68 commands (58 path-matched, 7 by change-kind, 5 whole-tree), stable across re-derivations. 61 passed clean throughout.Two real findings, both in this PR's own new test fixture, both fixed in-branch and re-verified green:
check:objectql-double-limitfind()double (engine-repo-execute-elevation.test.ts:52)check:where-matcher$-prefixed combinator read as a field namethrow), the exact idiomengine-readonly-strip-caller-values.test.ts's own fake driver already usesFive gates read
PREREQUISITE NOT MET/ an equivalent "build first" refusal — not a pass, not a finding, per this card's own instructions — every one needs a full monorepo build (all ~80 packages) this worktree never ran (only objectql's own dependency closure was built):check:test-completeness(needs a savedturbo run testlog),check:pm-half-states(needs GitHub API access this container lacks),check:dual-build-cjs-loadsandcheck:type-check-debt(need every workspace package'sdist/),check:skill-examples(needs@objectstack/client-reactbuilt — entirely unrelated to this PR'spackages/objectqlchange). All five read as NOT MEASURED locally; CI builds fresh and runs the full farm.node scripts/pm/check-governed-merges.mjs --test <final files>→ NOT governed (0 of the 4 paths hitGOVERNED_SURFACES) — ordinary queue landing applies.Gate re-derivation and the pin-test re-run above were both taken on the final head,
183cd58ec(git rev-parse --short HEAD).Clause-② (
needs:contract-review)Already applied by ruling clause 6 (content limb: behavioural shift on a surface reachable beyond the unauthenticated boundary) — left on. Independently, against the mechanical floor (any new key on a published payload is always
yes): this diff adds two new keys (executionContext,api) to the ctx object anObjectRepository.execute()-dispatched action handler receives ⇒ yes. Label stays; only the reviewing seat may clear it.Changeset
.changeset/repo-execute-elevated-context.md—@objectstack/objectql:patch. No new exported symbol, no published type moved — runtime behaviour change on an existing, previously-unreachable-in-practice method.Generated by Claude Code