fix(runtime): tell an action handler when its caller-scope record load was refused - #14247

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied
Sep 2, 2026
Merged

fix(runtime): tell an action handler when its caller-scope record load was refused#14247
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14143

An action handler could not tell "the caller cannot read this row" from "this action legitimately has no record". This PR gives it a signal that can, additively, and writes it into the action-authoring docs — half the defect was that none of it was written down.

Not an exploitation claim. No exploitability work was done and none is claimed. This is a predicate defect: the platform's most natural authorization predicate for an action handler was structurally always-true. The isSystem: true elevation is settled design (#3914) and is untouched.

The mechanism, re-derived on the current tree

Anchors were taken by triage on 20b79bea; re-derived here on 66ecc50a and they have not drifted — packages/runtime/src/action-execution.ts still carries :1117 (the elevation), :1282/:1283 (the caller-scope load), :1284 (the swallow), :1285 (the comment) and :1288 (the stamp) at those exact lines.

:1282constgot: any=awaitcallData('get',{object: objectName,id: recordId},driver,envId,ec);
:1283if(got?.record)record=got.record;
:1284}catch{
:1285/* new-record / record-less actions pass an empty record */
:1288if(record&&(recordasany).id==null&&recordId)(recordasany).id=recordId;

A refused or empty load leaves record as {}, so record.id is exactly null — which is the stamp's own condition. The stamp condition and the load-failure condition coincide. The body then runs elevated, so authorization has to be re-established inside the handler, and:

if(!ctx.record?.id)returnrefuse();// always false — never refused anything

⛔ The stamp is not deleted. Record-less / new-record actions legitimately depend on it, and that dependency is pinned in this PR in both directions.

Measured first: can the caught error already tell refusal from absence?

No — and the collapse is deliberate, not an oversight. Traced end to end:

  • callData('get', ...) prefers protocol.getData, which calls engine.findOne under the caller's context and, when nothing comes back, throws recordNotFoundError (packages/metadata-protocol/src/protocol.ts);
  • its ObjectQL fallback throws the identical error, imported from the same producer for exactly that reason (packages/core/src/utils/record-not-found.ts: code: 'RECORD_NOT_FOUND', status: 404);
  • so a row filtered out by row-level security and an id that names nothing arrive at :1284 as the same error object shape. The call site's own comment already says it: "engages the same permission path as get_record — an unseen record reads as not-found". That is existence non-disclosure working as designed.

⇒ The repair is not smaller than the card assumed, and it deliberately is not an inspection of the caught error: there is nothing there to inspect. It is a separate channel. Consequences for the design, both taken:

  • the flag reports "the row did not resolve for this caller", not "the platform caught an authorization error";
  • it carries no code and no status, so nobody is invited to branch on a distinction the read path fuses. For an authorization decision the two are one answer: this caller has not demonstrated read access to that row.

What was built — direction 1 (additive), as ruled

ctx.recordLoadDeniedtrue exactly when a caller-scope load was attempted and did not deliver the row; absent, never false, otherwise. Same absence semantics as the referentialFieldClear marker already on this seam, so a handler reads ctx.recordLoadDenied === true.

if(ctx.recordLoadDenied){throwObject.assign(newError('Record not available'),{code: 'RECORD_NOT_FOUND'});}

Nothing that reaches a handler today stops reaching it. No existing key changes value. Direction 2 (pre-dispatch refusal) was not taken and no published accept set is narrowed.

Files, and why each one is required rather than scope creep

FileWhy
packages/runtime/src/action-execution.tsThe card's named site. New loadActionSubjectRecord — one producer for the load and its verdict — plus the signal on the MCP run_action context.
packages/runtime/src/domains/actions.tsThe REST /actions door carries the identical defect, byte-for-byte: git grep for the stamp returns exactly two hits, this one and the above. A documented guard that only one of two doors sets is an authorization guard silently inert on the other — the same defect one door over. Converted to call the same producer; no other behaviour changed.
packages/runtime/src/sandbox/script-runner.ts, body-runner.ts, quickjs-runner.tsThe sandbox ctx is a fixed key set — a key the dispatcher sets but the sandbox never marshals reads as undefined inside every inline body. Without these three edits the documented guard would be false on the surface an AI author writes most. Declared on ScriptContext, projected in buildActionSandboxContext, installed true-only in installCtx — the exact shape referentialFieldClear already uses two lines above.
content/docs/ui/actions.mdx, content/docs/automation/hook-bodies.mdxBinding requirement of the dispatch order, and the reporter's actual complaint. New "Authorization inside an action" section with the wrong guard, the right guard, and a three-row table; cross-referenced from the trusted-elevation callout and from the action-ctx paragraph in the hook-bodies reference.
packages/runtime/src/action-record-load-denied.test.ts, .changeset/action-record-load-denied-signal.mdVerification and release note.

packages/spec is not in the diff — the Clause ② path limb does not fire (the engine contract types executeAction's ctx as any, and no ActionContext schema exists in spec).

Verification

Everything below was run on 603d1236, this branch's head — the tree was clean and unchanged from that commit for the whole measurement.

Whole affected package, green:

pnpm --filter @objectstack/runtime test
Test Files 206 passed (206) Tests 3053 passed (3053)

Testspackages/runtime/src/action-record-load-denied.test.ts, 13 cases. The engine double is row-scoped on the one point that matters: find honours options.context.userId, so the row is returned to its owner and is invisible to anyone else — which is how row-level security actually manifests to callData('get', ...). The MCP leg is wired to the realcallData, so the real recordNotFoundError is what the dispatcher catches; nothing about the refused/absent collapse is mocked away.

pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 \
src/action-record-load-denied.test.ts src/action-ctx-user-shape.test.ts \
src/action-body-identity.test.ts src/http-dispatcher.actions-global-key.test.ts \
src/action-execution-calldata-not-found.test.ts
Test Files 5 passed (5) Tests 89 passed (89)

Both directions pinned, on both doors:

  • a caller who cannot read the row reaches the handler with recordLoadDenied === trueand ctx.record.id is still there, which is the prohibited regression, asserted rather than assumed;
  • the row owner reaches the handler with the real row and the key absent ('recordLoadDenied' in ctx === false);
  • an object-less action invoked with a recordId attempts no load and still gets the stamp;
  • a new-record invocation (no recordId) attempts no load, gets no flag, and no subject-row read is issued at all.

Every absence assertion has a firing positive control on the same rig: the identical expectation shape reports true for the unauthorized caller.

Reverse verification — three ablations, each on the committed tree, each proving the mutation landed on disk by occurrence counts (not by an editor's exit code) and each restoring under a trap whose success is proven by comparing git hash-object against the HEAD blob:

AblationResult
actionRecordLoadSignal returns {} (kills the signal at both doors)3 failed / 10 passed — both door tests and the producer test red; every stamp-regression and body test stayed green
the recordLoadDenied projection removed from buildActionSandboxContext1 failed / 12 passed — only the body-face test
vm.setProp(ctxObj, 'recordLoadDenied', ...) removed from installCtx1 failed / 12 passed — only the body-face test

No rebuild is needed for these: the test imports its subjects by relative specifier (./action-execution.js, ./sandbox/body-runner.js), which vite resolves to packages/runtime/src/*.ts — the package's dist is never on the path, and packages/runtime/vitest.config.ts declares no alias that touches a relative import.

Typecheckpnpm --filter @objectstack/runtime typecheck green, and measured rather than assumed: tsc --listFiles reports 5 of 5 edited source files inside that program. That config excludes **/*.test.ts, so the new test file was type-checked separately against the same compiler options with the exclusion lifted — also green.

Gates run locally (the farm runs in CI; these are the ones this diff implicates): check:nul-bytes · check:doc-anchors (295 fragment links resolve, including the two added here) · check:docs-single-h1 · check-system-context-census (109 elevation read sites, all anchored — this gate reads both edited dispatcher files by exact path) · check:route-envelope · check:doc-authoring · check:cross-package-test-inputs · check:test-source-alias · check-doc-frontmatter · check-doc-route-spelling · check-docs-section-name · check-keyed-text-bounds · check-comment-mask-adoption · check-undeclared-dep-imports · check:type-source-resolution · check:objectql-double-limit · check:where-matcher · check-empty-changeset · check-adr-0087-registration · check-changeset-no-major · check:changeset-gate-self-tests · check:objectui-changeset · check:docs-audit-scope · check:corpus-claim-drift · check:doc-security-posture · check:role-word · docs-audit/check-affected-docs · pm/check-half-states · check:published-files · check-ci-filter-parity — all green.

check-test-completeness exits 3 (PREREQUISITE NOT MET) with no argument: it grades a saved turbo run test log that only CI produces. Recorded as NOT MEASURED, per that script's own instruction.

Stop conditions checked

Out of scope, filed rather than fixed

The flow branch of the same two doors hands the same stamped record stub to AutomationContext, and the flow face has no counterpart signal. It is a narrower and conditional claim (a flow action is not system-elevated by default — the flow engine honours runAs, ADR-0049), and ruling it is a design question this card does not own. Filed unassigned as #14244.


Generated by Claude Code

…d was refused
Add `ctx.recordLoadDenied` — true exactly when a caller-scope subject-record
load was attempted and did not deliver the row, absent otherwise. Both action
doors (REST /actions and the MCP run_action bridge) now share one producer,
`loadActionSubjectRecord`, and the flag is marshalled explicitly into the body
sandbox so an inline body reads it as a registered handler does.
The `recordId` stamp is deliberately kept: record-less / new-record actions
depend on it, which is why the stamp condition and the load-failure condition
coincided and `if (!ctx.record?.id)` never refused anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))
What this run could not see
  • 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 — 23 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 b360cc7d5ca2e9cdb60a12018af20cbc3470cafapackageMentionDocs.

Which tree this was computed on

This run read content/docs from ea206df91049f4659826d26335bbe91463109f9c — the merge of head 603d12366c73f04ba6b38174be6e9a8b18291786 into base b360cc7d5ca2e9cdb60a12018af20cbc3470cafa, 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 ea206df91049f4659826d26335bbe91463109f9c && git checkout ea206df91049f4659826d26335bbe91463109f9c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b360cc7d5ca2e9cdb60a12018af20cbc3470cafa 603d12366c73f04ba6b38174be6e9a8b18291786 && git checkout -B drift-repro b360cc7d5ca2e9cdb60a12018af20cbc3470cafa && git merge --no-ff 603d12366c73f04ba6b38174be6e9a8b18291786
node scripts/docs-audit/affected-docs.mjs --json b360cc7d5ca2e9cdb60a12018af20cbc3470cafa

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit f19475cSep 2, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14143-action-record-load-denied branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(runtime): tell an action handler when its caller-scope record load was refused - #14247

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied
Sep 2, 2026
Merged

fix(runtime): tell an action handler when its caller-scope record load was refused#14247
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14143

An action handler could not tell "the caller cannot read this row" from "this action legitimately has no record". This PR gives it a signal that can, additively, and writes it into the action-authoring docs — half the defect was that none of it was written down.

Not an exploitation claim. No exploitability work was done and none is claimed. This is a predicate defect: the platform's most natural authorization predicate for an action handler was structurally always-true. The isSystem: true elevation is settled design (#3914) and is untouched.

The mechanism, re-derived on the current tree

Anchors were taken by triage on 20b79bea; re-derived here on 66ecc50a and they have not drifted — packages/runtime/src/action-execution.ts still carries :1117 (the elevation), :1282/:1283 (the caller-scope load), :1284 (the swallow), :1285 (the comment) and :1288 (the stamp) at those exact lines.

:1282constgot: any=awaitcallData('get',{object: objectName,id: recordId},driver,envId,ec);
:1283if(got?.record)record=got.record;
:1284}catch{
:1285/* new-record / record-less actions pass an empty record */
:1288if(record&&(recordasany).id==null&&recordId)(recordasany).id=recordId;

A refused or empty load leaves record as {}, so record.id is exactly null — which is the stamp's own condition. The stamp condition and the load-failure condition coincide. The body then runs elevated, so authorization has to be re-established inside the handler, and:

if(!ctx.record?.id)returnrefuse();// always false — never refused anything

⛔ The stamp is not deleted. Record-less / new-record actions legitimately depend on it, and that dependency is pinned in this PR in both directions.

Measured first: can the caught error already tell refusal from absence?

No — and the collapse is deliberate, not an oversight. Traced end to end:

  • callData('get', ...) prefers protocol.getData, which calls engine.findOne under the caller's context and, when nothing comes back, throws recordNotFoundError (packages/metadata-protocol/src/protocol.ts);
  • its ObjectQL fallback throws the identical error, imported from the same producer for exactly that reason (packages/core/src/utils/record-not-found.ts: code: 'RECORD_NOT_FOUND', status: 404);
  • so a row filtered out by row-level security and an id that names nothing arrive at :1284 as the same error object shape. The call site's own comment already says it: "engages the same permission path as get_record — an unseen record reads as not-found". That is existence non-disclosure working as designed.

⇒ The repair is not smaller than the card assumed, and it deliberately is not an inspection of the caught error: there is nothing there to inspect. It is a separate channel. Consequences for the design, both taken:

  • the flag reports "the row did not resolve for this caller", not "the platform caught an authorization error";
  • it carries no code and no status, so nobody is invited to branch on a distinction the read path fuses. For an authorization decision the two are one answer: this caller has not demonstrated read access to that row.

What was built — direction 1 (additive), as ruled

ctx.recordLoadDeniedtrue exactly when a caller-scope load was attempted and did not deliver the row; absent, never false, otherwise. Same absence semantics as the referentialFieldClear marker already on this seam, so a handler reads ctx.recordLoadDenied === true.

if(ctx.recordLoadDenied){throwObject.assign(newError('Record not available'),{code: 'RECORD_NOT_FOUND'});}

Nothing that reaches a handler today stops reaching it. No existing key changes value. Direction 2 (pre-dispatch refusal) was not taken and no published accept set is narrowed.

Files, and why each one is required rather than scope creep

FileWhy
packages/runtime/src/action-execution.tsThe card's named site. New loadActionSubjectRecord — one producer for the load and its verdict — plus the signal on the MCP run_action context.
packages/runtime/src/domains/actions.tsThe REST /actions door carries the identical defect, byte-for-byte: git grep for the stamp returns exactly two hits, this one and the above. A documented guard that only one of two doors sets is an authorization guard silently inert on the other — the same defect one door over. Converted to call the same producer; no other behaviour changed.
packages/runtime/src/sandbox/script-runner.ts, body-runner.ts, quickjs-runner.tsThe sandbox ctx is a fixed key set — a key the dispatcher sets but the sandbox never marshals reads as undefined inside every inline body. Without these three edits the documented guard would be false on the surface an AI author writes most. Declared on ScriptContext, projected in buildActionSandboxContext, installed true-only in installCtx — the exact shape referentialFieldClear already uses two lines above.
content/docs/ui/actions.mdx, content/docs/automation/hook-bodies.mdxBinding requirement of the dispatch order, and the reporter's actual complaint. New "Authorization inside an action" section with the wrong guard, the right guard, and a three-row table; cross-referenced from the trusted-elevation callout and from the action-ctx paragraph in the hook-bodies reference.
packages/runtime/src/action-record-load-denied.test.ts, .changeset/action-record-load-denied-signal.mdVerification and release note.

packages/spec is not in the diff — the Clause ② path limb does not fire (the engine contract types executeAction's ctx as any, and no ActionContext schema exists in spec).

Verification

Everything below was run on 603d1236, this branch's head — the tree was clean and unchanged from that commit for the whole measurement.

Whole affected package, green:

pnpm --filter @objectstack/runtime test
Test Files 206 passed (206) Tests 3053 passed (3053)

Testspackages/runtime/src/action-record-load-denied.test.ts, 13 cases. The engine double is row-scoped on the one point that matters: find honours options.context.userId, so the row is returned to its owner and is invisible to anyone else — which is how row-level security actually manifests to callData('get', ...). The MCP leg is wired to the realcallData, so the real recordNotFoundError is what the dispatcher catches; nothing about the refused/absent collapse is mocked away.

pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 \
src/action-record-load-denied.test.ts src/action-ctx-user-shape.test.ts \
src/action-body-identity.test.ts src/http-dispatcher.actions-global-key.test.ts \
src/action-execution-calldata-not-found.test.ts
Test Files 5 passed (5) Tests 89 passed (89)

Both directions pinned, on both doors:

  • a caller who cannot read the row reaches the handler with recordLoadDenied === trueand ctx.record.id is still there, which is the prohibited regression, asserted rather than assumed;
  • the row owner reaches the handler with the real row and the key absent ('recordLoadDenied' in ctx === false);
  • an object-less action invoked with a recordId attempts no load and still gets the stamp;
  • a new-record invocation (no recordId) attempts no load, gets no flag, and no subject-row read is issued at all.

Every absence assertion has a firing positive control on the same rig: the identical expectation shape reports true for the unauthorized caller.

Reverse verification — three ablations, each on the committed tree, each proving the mutation landed on disk by occurrence counts (not by an editor's exit code) and each restoring under a trap whose success is proven by comparing git hash-object against the HEAD blob:

AblationResult
actionRecordLoadSignal returns {} (kills the signal at both doors)3 failed / 10 passed — both door tests and the producer test red; every stamp-regression and body test stayed green
the recordLoadDenied projection removed from buildActionSandboxContext1 failed / 12 passed — only the body-face test
vm.setProp(ctxObj, 'recordLoadDenied', ...) removed from installCtx1 failed / 12 passed — only the body-face test

No rebuild is needed for these: the test imports its subjects by relative specifier (./action-execution.js, ./sandbox/body-runner.js), which vite resolves to packages/runtime/src/*.ts — the package's dist is never on the path, and packages/runtime/vitest.config.ts declares no alias that touches a relative import.

Typecheckpnpm --filter @objectstack/runtime typecheck green, and measured rather than assumed: tsc --listFiles reports 5 of 5 edited source files inside that program. That config excludes **/*.test.ts, so the new test file was type-checked separately against the same compiler options with the exclusion lifted — also green.

Gates run locally (the farm runs in CI; these are the ones this diff implicates): check:nul-bytes · check:doc-anchors (295 fragment links resolve, including the two added here) · check:docs-single-h1 · check-system-context-census (109 elevation read sites, all anchored — this gate reads both edited dispatcher files by exact path) · check:route-envelope · check:doc-authoring · check:cross-package-test-inputs · check:test-source-alias · check-doc-frontmatter · check-doc-route-spelling · check-docs-section-name · check-keyed-text-bounds · check-comment-mask-adoption · check-undeclared-dep-imports · check:type-source-resolution · check:objectql-double-limit · check:where-matcher · check-empty-changeset · check-adr-0087-registration · check-changeset-no-major · check:changeset-gate-self-tests · check:objectui-changeset · check:docs-audit-scope · check:corpus-claim-drift · check:doc-security-posture · check:role-word · docs-audit/check-affected-docs · pm/check-half-states · check:published-files · check-ci-filter-parity — all green.

check-test-completeness exits 3 (PREREQUISITE NOT MET) with no argument: it grades a saved turbo run test log that only CI produces. Recorded as NOT MEASURED, per that script's own instruction.

Stop conditions checked

Out of scope, filed rather than fixed

The flow branch of the same two doors hands the same stamped record stub to AutomationContext, and the flow face has no counterpart signal. It is a narrower and conditional claim (a flow action is not system-elevated by default — the flow engine honours runAs, ADR-0049), and ruling it is a design question this card does not own. Filed unassigned as #14244.


Generated by Claude Code

…d was refused
Add `ctx.recordLoadDenied` — true exactly when a caller-scope subject-record
load was attempted and did not deliver the row, absent otherwise. Both action
doors (REST /actions and the MCP run_action bridge) now share one producer,
`loadActionSubjectRecord`, and the flag is marshalled explicitly into the body
sandbox so an inline body reads it as a registered handler does.
The `recordId` stamp is deliberately kept: record-less / new-record actions
depend on it, which is why the stamp condition and the load-failure condition
coincided and `if (!ctx.record?.id)` never refused anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))
What this run could not see
  • 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 — 23 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 b360cc7d5ca2e9cdb60a12018af20cbc3470cafapackageMentionDocs.

Which tree this was computed on

This run read content/docs from ea206df91049f4659826d26335bbe91463109f9c — the merge of head 603d12366c73f04ba6b38174be6e9a8b18291786 into base b360cc7d5ca2e9cdb60a12018af20cbc3470cafa, 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 ea206df91049f4659826d26335bbe91463109f9c && git checkout ea206df91049f4659826d26335bbe91463109f9c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b360cc7d5ca2e9cdb60a12018af20cbc3470cafa 603d12366c73f04ba6b38174be6e9a8b18291786 && git checkout -B drift-repro b360cc7d5ca2e9cdb60a12018af20cbc3470cafa && git merge --no-ff 603d12366c73f04ba6b38174be6e9a8b18291786
node scripts/docs-audit/affected-docs.mjs --json b360cc7d5ca2e9cdb60a12018af20cbc3470cafa

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit f19475cSep 2, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14143-action-record-load-denied branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): tell an action handler when its caller-scope record load was refused - #14247

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied
Sep 2, 2026
Merged

fix(runtime): tell an action handler when its caller-scope record load was refused#14247
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14143

An action handler could not tell "the caller cannot read this row" from "this action legitimately has no record". This PR gives it a signal that can, additively, and writes it into the action-authoring docs — half the defect was that none of it was written down.

Not an exploitation claim. No exploitability work was done and none is claimed. This is a predicate defect: the platform's most natural authorization predicate for an action handler was structurally always-true. The isSystem: true elevation is settled design (#3914) and is untouched.

The mechanism, re-derived on the current tree

Anchors were taken by triage on 20b79bea; re-derived here on 66ecc50a and they have not drifted — packages/runtime/src/action-execution.ts still carries :1117 (the elevation), :1282/:1283 (the caller-scope load), :1284 (the swallow), :1285 (the comment) and :1288 (the stamp) at those exact lines.

:1282constgot: any=awaitcallData('get',{object: objectName,id: recordId},driver,envId,ec);
:1283if(got?.record)record=got.record;
:1284}catch{
:1285/* new-record / record-less actions pass an empty record */
:1288if(record&&(recordasany).id==null&&recordId)(recordasany).id=recordId;

A refused or empty load leaves record as {}, so record.id is exactly null — which is the stamp's own condition. The stamp condition and the load-failure condition coincide. The body then runs elevated, so authorization has to be re-established inside the handler, and:

if(!ctx.record?.id)returnrefuse();// always false — never refused anything

⛔ The stamp is not deleted. Record-less / new-record actions legitimately depend on it, and that dependency is pinned in this PR in both directions.

Measured first: can the caught error already tell refusal from absence?

No — and the collapse is deliberate, not an oversight. Traced end to end:

  • callData('get', ...) prefers protocol.getData, which calls engine.findOne under the caller's context and, when nothing comes back, throws recordNotFoundError (packages/metadata-protocol/src/protocol.ts);
  • its ObjectQL fallback throws the identical error, imported from the same producer for exactly that reason (packages/core/src/utils/record-not-found.ts: code: 'RECORD_NOT_FOUND', status: 404);
  • so a row filtered out by row-level security and an id that names nothing arrive at :1284 as the same error object shape. The call site's own comment already says it: "engages the same permission path as get_record — an unseen record reads as not-found". That is existence non-disclosure working as designed.

⇒ The repair is not smaller than the card assumed, and it deliberately is not an inspection of the caught error: there is nothing there to inspect. It is a separate channel. Consequences for the design, both taken:

  • the flag reports "the row did not resolve for this caller", not "the platform caught an authorization error";
  • it carries no code and no status, so nobody is invited to branch on a distinction the read path fuses. For an authorization decision the two are one answer: this caller has not demonstrated read access to that row.

What was built — direction 1 (additive), as ruled

ctx.recordLoadDeniedtrue exactly when a caller-scope load was attempted and did not deliver the row; absent, never false, otherwise. Same absence semantics as the referentialFieldClear marker already on this seam, so a handler reads ctx.recordLoadDenied === true.

if(ctx.recordLoadDenied){throwObject.assign(newError('Record not available'),{code: 'RECORD_NOT_FOUND'});}

Nothing that reaches a handler today stops reaching it. No existing key changes value. Direction 2 (pre-dispatch refusal) was not taken and no published accept set is narrowed.

Files, and why each one is required rather than scope creep

FileWhy
packages/runtime/src/action-execution.tsThe card's named site. New loadActionSubjectRecord — one producer for the load and its verdict — plus the signal on the MCP run_action context.
packages/runtime/src/domains/actions.tsThe REST /actions door carries the identical defect, byte-for-byte: git grep for the stamp returns exactly two hits, this one and the above. A documented guard that only one of two doors sets is an authorization guard silently inert on the other — the same defect one door over. Converted to call the same producer; no other behaviour changed.
packages/runtime/src/sandbox/script-runner.ts, body-runner.ts, quickjs-runner.tsThe sandbox ctx is a fixed key set — a key the dispatcher sets but the sandbox never marshals reads as undefined inside every inline body. Without these three edits the documented guard would be false on the surface an AI author writes most. Declared on ScriptContext, projected in buildActionSandboxContext, installed true-only in installCtx — the exact shape referentialFieldClear already uses two lines above.
content/docs/ui/actions.mdx, content/docs/automation/hook-bodies.mdxBinding requirement of the dispatch order, and the reporter's actual complaint. New "Authorization inside an action" section with the wrong guard, the right guard, and a three-row table; cross-referenced from the trusted-elevation callout and from the action-ctx paragraph in the hook-bodies reference.
packages/runtime/src/action-record-load-denied.test.ts, .changeset/action-record-load-denied-signal.mdVerification and release note.

packages/spec is not in the diff — the Clause ② path limb does not fire (the engine contract types executeAction's ctx as any, and no ActionContext schema exists in spec).

Verification

Everything below was run on 603d1236, this branch's head — the tree was clean and unchanged from that commit for the whole measurement.

Whole affected package, green:

pnpm --filter @objectstack/runtime test
Test Files 206 passed (206) Tests 3053 passed (3053)

Testspackages/runtime/src/action-record-load-denied.test.ts, 13 cases. The engine double is row-scoped on the one point that matters: find honours options.context.userId, so the row is returned to its owner and is invisible to anyone else — which is how row-level security actually manifests to callData('get', ...). The MCP leg is wired to the realcallData, so the real recordNotFoundError is what the dispatcher catches; nothing about the refused/absent collapse is mocked away.

pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 \
src/action-record-load-denied.test.ts src/action-ctx-user-shape.test.ts \
src/action-body-identity.test.ts src/http-dispatcher.actions-global-key.test.ts \
src/action-execution-calldata-not-found.test.ts
Test Files 5 passed (5) Tests 89 passed (89)

Both directions pinned, on both doors:

  • a caller who cannot read the row reaches the handler with recordLoadDenied === trueand ctx.record.id is still there, which is the prohibited regression, asserted rather than assumed;
  • the row owner reaches the handler with the real row and the key absent ('recordLoadDenied' in ctx === false);
  • an object-less action invoked with a recordId attempts no load and still gets the stamp;
  • a new-record invocation (no recordId) attempts no load, gets no flag, and no subject-row read is issued at all.

Every absence assertion has a firing positive control on the same rig: the identical expectation shape reports true for the unauthorized caller.

Reverse verification — three ablations, each on the committed tree, each proving the mutation landed on disk by occurrence counts (not by an editor's exit code) and each restoring under a trap whose success is proven by comparing git hash-object against the HEAD blob:

AblationResult
actionRecordLoadSignal returns {} (kills the signal at both doors)3 failed / 10 passed — both door tests and the producer test red; every stamp-regression and body test stayed green
the recordLoadDenied projection removed from buildActionSandboxContext1 failed / 12 passed — only the body-face test
vm.setProp(ctxObj, 'recordLoadDenied', ...) removed from installCtx1 failed / 12 passed — only the body-face test

No rebuild is needed for these: the test imports its subjects by relative specifier (./action-execution.js, ./sandbox/body-runner.js), which vite resolves to packages/runtime/src/*.ts — the package's dist is never on the path, and packages/runtime/vitest.config.ts declares no alias that touches a relative import.

Typecheckpnpm --filter @objectstack/runtime typecheck green, and measured rather than assumed: tsc --listFiles reports 5 of 5 edited source files inside that program. That config excludes **/*.test.ts, so the new test file was type-checked separately against the same compiler options with the exclusion lifted — also green.

Gates run locally (the farm runs in CI; these are the ones this diff implicates): check:nul-bytes · check:doc-anchors (295 fragment links resolve, including the two added here) · check:docs-single-h1 · check-system-context-census (109 elevation read sites, all anchored — this gate reads both edited dispatcher files by exact path) · check:route-envelope · check:doc-authoring · check:cross-package-test-inputs · check:test-source-alias · check-doc-frontmatter · check-doc-route-spelling · check-docs-section-name · check-keyed-text-bounds · check-comment-mask-adoption · check-undeclared-dep-imports · check:type-source-resolution · check:objectql-double-limit · check:where-matcher · check-empty-changeset · check-adr-0087-registration · check-changeset-no-major · check:changeset-gate-self-tests · check:objectui-changeset · check:docs-audit-scope · check:corpus-claim-drift · check:doc-security-posture · check:role-word · docs-audit/check-affected-docs · pm/check-half-states · check:published-files · check-ci-filter-parity — all green.

check-test-completeness exits 3 (PREREQUISITE NOT MET) with no argument: it grades a saved turbo run test log that only CI produces. Recorded as NOT MEASURED, per that script's own instruction.

Stop conditions checked

Out of scope, filed rather than fixed

The flow branch of the same two doors hands the same stamped record stub to AutomationContext, and the flow face has no counterpart signal. It is a narrower and conditional claim (a flow action is not system-elevated by default — the flow engine honours runAs, ADR-0049), and ruling it is a design question this card does not own. Filed unassigned as #14244.


Generated by Claude Code

…d was refused
Add `ctx.recordLoadDenied` — true exactly when a caller-scope subject-record
load was attempted and did not deliver the row, absent otherwise. Both action
doors (REST /actions and the MCP run_action bridge) now share one producer,
`loadActionSubjectRecord`, and the flag is marshalled explicitly into the body
sandbox so an inline body reads it as a registered handler does.
The `recordId` stamp is deliberately kept: record-less / new-record actions
depend on it, which is why the stamp condition and the load-failure condition
coincided and `if (!ctx.record?.id)` never refused anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))
What this run could not see
  • 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 — 23 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 b360cc7d5ca2e9cdb60a12018af20cbc3470cafapackageMentionDocs.

Which tree this was computed on

This run read content/docs from ea206df91049f4659826d26335bbe91463109f9c — the merge of head 603d12366c73f04ba6b38174be6e9a8b18291786 into base b360cc7d5ca2e9cdb60a12018af20cbc3470cafa, 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 ea206df91049f4659826d26335bbe91463109f9c && git checkout ea206df91049f4659826d26335bbe91463109f9c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b360cc7d5ca2e9cdb60a12018af20cbc3470cafa 603d12366c73f04ba6b38174be6e9a8b18291786 && git checkout -B drift-repro b360cc7d5ca2e9cdb60a12018af20cbc3470cafa && git merge --no-ff 603d12366c73f04ba6b38174be6e9a8b18291786
node scripts/docs-audit/affected-docs.mjs --json b360cc7d5ca2e9cdb60a12018af20cbc3470cafa

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit f19475cSep 2, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14143-action-record-load-denied branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): tell an action handler when its caller-scope record load was refused - #14247

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied
Sep 2, 2026
Merged

fix(runtime): tell an action handler when its caller-scope record load was refused#14247
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14143

An action handler could not tell "the caller cannot read this row" from "this action legitimately has no record". This PR gives it a signal that can, additively, and writes it into the action-authoring docs — half the defect was that none of it was written down.

Not an exploitation claim. No exploitability work was done and none is claimed. This is a predicate defect: the platform's most natural authorization predicate for an action handler was structurally always-true. The isSystem: true elevation is settled design (#3914) and is untouched.

The mechanism, re-derived on the current tree

Anchors were taken by triage on 20b79bea; re-derived here on 66ecc50a and they have not drifted — packages/runtime/src/action-execution.ts still carries :1117 (the elevation), :1282/:1283 (the caller-scope load), :1284 (the swallow), :1285 (the comment) and :1288 (the stamp) at those exact lines.

:1282constgot: any=awaitcallData('get',{object: objectName,id: recordId},driver,envId,ec);
:1283if(got?.record)record=got.record;
:1284}catch{
:1285/* new-record / record-less actions pass an empty record */
:1288if(record&&(recordasany).id==null&&recordId)(recordasany).id=recordId;

A refused or empty load leaves record as {}, so record.id is exactly null — which is the stamp's own condition. The stamp condition and the load-failure condition coincide. The body then runs elevated, so authorization has to be re-established inside the handler, and:

if(!ctx.record?.id)returnrefuse();// always false — never refused anything

⛔ The stamp is not deleted. Record-less / new-record actions legitimately depend on it, and that dependency is pinned in this PR in both directions.

Measured first: can the caught error already tell refusal from absence?

No — and the collapse is deliberate, not an oversight. Traced end to end:

  • callData('get', ...) prefers protocol.getData, which calls engine.findOne under the caller's context and, when nothing comes back, throws recordNotFoundError (packages/metadata-protocol/src/protocol.ts);
  • its ObjectQL fallback throws the identical error, imported from the same producer for exactly that reason (packages/core/src/utils/record-not-found.ts: code: 'RECORD_NOT_FOUND', status: 404);
  • so a row filtered out by row-level security and an id that names nothing arrive at :1284 as the same error object shape. The call site's own comment already says it: "engages the same permission path as get_record — an unseen record reads as not-found". That is existence non-disclosure working as designed.

⇒ The repair is not smaller than the card assumed, and it deliberately is not an inspection of the caught error: there is nothing there to inspect. It is a separate channel. Consequences for the design, both taken:

  • the flag reports "the row did not resolve for this caller", not "the platform caught an authorization error";
  • it carries no code and no status, so nobody is invited to branch on a distinction the read path fuses. For an authorization decision the two are one answer: this caller has not demonstrated read access to that row.

What was built — direction 1 (additive), as ruled

ctx.recordLoadDeniedtrue exactly when a caller-scope load was attempted and did not deliver the row; absent, never false, otherwise. Same absence semantics as the referentialFieldClear marker already on this seam, so a handler reads ctx.recordLoadDenied === true.

if(ctx.recordLoadDenied){throwObject.assign(newError('Record not available'),{code: 'RECORD_NOT_FOUND'});}

Nothing that reaches a handler today stops reaching it. No existing key changes value. Direction 2 (pre-dispatch refusal) was not taken and no published accept set is narrowed.

Files, and why each one is required rather than scope creep

FileWhy
packages/runtime/src/action-execution.tsThe card's named site. New loadActionSubjectRecord — one producer for the load and its verdict — plus the signal on the MCP run_action context.
packages/runtime/src/domains/actions.tsThe REST /actions door carries the identical defect, byte-for-byte: git grep for the stamp returns exactly two hits, this one and the above. A documented guard that only one of two doors sets is an authorization guard silently inert on the other — the same defect one door over. Converted to call the same producer; no other behaviour changed.
packages/runtime/src/sandbox/script-runner.ts, body-runner.ts, quickjs-runner.tsThe sandbox ctx is a fixed key set — a key the dispatcher sets but the sandbox never marshals reads as undefined inside every inline body. Without these three edits the documented guard would be false on the surface an AI author writes most. Declared on ScriptContext, projected in buildActionSandboxContext, installed true-only in installCtx — the exact shape referentialFieldClear already uses two lines above.
content/docs/ui/actions.mdx, content/docs/automation/hook-bodies.mdxBinding requirement of the dispatch order, and the reporter's actual complaint. New "Authorization inside an action" section with the wrong guard, the right guard, and a three-row table; cross-referenced from the trusted-elevation callout and from the action-ctx paragraph in the hook-bodies reference.
packages/runtime/src/action-record-load-denied.test.ts, .changeset/action-record-load-denied-signal.mdVerification and release note.

packages/spec is not in the diff — the Clause ② path limb does not fire (the engine contract types executeAction's ctx as any, and no ActionContext schema exists in spec).

Verification

Everything below was run on 603d1236, this branch's head — the tree was clean and unchanged from that commit for the whole measurement.

Whole affected package, green:

pnpm --filter @objectstack/runtime test
Test Files 206 passed (206) Tests 3053 passed (3053)

Testspackages/runtime/src/action-record-load-denied.test.ts, 13 cases. The engine double is row-scoped on the one point that matters: find honours options.context.userId, so the row is returned to its owner and is invisible to anyone else — which is how row-level security actually manifests to callData('get', ...). The MCP leg is wired to the realcallData, so the real recordNotFoundError is what the dispatcher catches; nothing about the refused/absent collapse is mocked away.

pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 \
src/action-record-load-denied.test.ts src/action-ctx-user-shape.test.ts \
src/action-body-identity.test.ts src/http-dispatcher.actions-global-key.test.ts \
src/action-execution-calldata-not-found.test.ts
Test Files 5 passed (5) Tests 89 passed (89)

Both directions pinned, on both doors:

  • a caller who cannot read the row reaches the handler with recordLoadDenied === trueand ctx.record.id is still there, which is the prohibited regression, asserted rather than assumed;
  • the row owner reaches the handler with the real row and the key absent ('recordLoadDenied' in ctx === false);
  • an object-less action invoked with a recordId attempts no load and still gets the stamp;
  • a new-record invocation (no recordId) attempts no load, gets no flag, and no subject-row read is issued at all.

Every absence assertion has a firing positive control on the same rig: the identical expectation shape reports true for the unauthorized caller.

Reverse verification — three ablations, each on the committed tree, each proving the mutation landed on disk by occurrence counts (not by an editor's exit code) and each restoring under a trap whose success is proven by comparing git hash-object against the HEAD blob:

AblationResult
actionRecordLoadSignal returns {} (kills the signal at both doors)3 failed / 10 passed — both door tests and the producer test red; every stamp-regression and body test stayed green
the recordLoadDenied projection removed from buildActionSandboxContext1 failed / 12 passed — only the body-face test
vm.setProp(ctxObj, 'recordLoadDenied', ...) removed from installCtx1 failed / 12 passed — only the body-face test

No rebuild is needed for these: the test imports its subjects by relative specifier (./action-execution.js, ./sandbox/body-runner.js), which vite resolves to packages/runtime/src/*.ts — the package's dist is never on the path, and packages/runtime/vitest.config.ts declares no alias that touches a relative import.

Typecheckpnpm --filter @objectstack/runtime typecheck green, and measured rather than assumed: tsc --listFiles reports 5 of 5 edited source files inside that program. That config excludes **/*.test.ts, so the new test file was type-checked separately against the same compiler options with the exclusion lifted — also green.

Gates run locally (the farm runs in CI; these are the ones this diff implicates): check:nul-bytes · check:doc-anchors (295 fragment links resolve, including the two added here) · check:docs-single-h1 · check-system-context-census (109 elevation read sites, all anchored — this gate reads both edited dispatcher files by exact path) · check:route-envelope · check:doc-authoring · check:cross-package-test-inputs · check:test-source-alias · check-doc-frontmatter · check-doc-route-spelling · check-docs-section-name · check-keyed-text-bounds · check-comment-mask-adoption · check-undeclared-dep-imports · check:type-source-resolution · check:objectql-double-limit · check:where-matcher · check-empty-changeset · check-adr-0087-registration · check-changeset-no-major · check:changeset-gate-self-tests · check:objectui-changeset · check:docs-audit-scope · check:corpus-claim-drift · check:doc-security-posture · check:role-word · docs-audit/check-affected-docs · pm/check-half-states · check:published-files · check-ci-filter-parity — all green.

check-test-completeness exits 3 (PREREQUISITE NOT MET) with no argument: it grades a saved turbo run test log that only CI produces. Recorded as NOT MEASURED, per that script's own instruction.

Stop conditions checked

Out of scope, filed rather than fixed

The flow branch of the same two doors hands the same stamped record stub to AutomationContext, and the flow face has no counterpart signal. It is a narrower and conditional claim (a flow action is not system-elevated by default — the flow engine honours runAs, ADR-0049), and ruling it is a design question this card does not own. Filed unassigned as #14244.


Generated by Claude Code

…d was refused
Add `ctx.recordLoadDenied` — true exactly when a caller-scope subject-record
load was attempted and did not deliver the row, absent otherwise. Both action
doors (REST /actions and the MCP run_action bridge) now share one producer,
`loadActionSubjectRecord`, and the flag is marshalled explicitly into the body
sandbox so an inline body reads it as a registered handler does.
The `recordId` stamp is deliberately kept: record-less / new-record actions
depend on it, which is why the stamp condition and the load-failure condition
coincided and `if (!ctx.record?.id)` never refused anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))
What this run could not see
  • 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 — 23 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 b360cc7d5ca2e9cdb60a12018af20cbc3470cafapackageMentionDocs.

Which tree this was computed on

This run read content/docs from ea206df91049f4659826d26335bbe91463109f9c — the merge of head 603d12366c73f04ba6b38174be6e9a8b18291786 into base b360cc7d5ca2e9cdb60a12018af20cbc3470cafa, 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 ea206df91049f4659826d26335bbe91463109f9c && git checkout ea206df91049f4659826d26335bbe91463109f9c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b360cc7d5ca2e9cdb60a12018af20cbc3470cafa 603d12366c73f04ba6b38174be6e9a8b18291786 && git checkout -B drift-repro b360cc7d5ca2e9cdb60a12018af20cbc3470cafa && git merge --no-ff 603d12366c73f04ba6b38174be6e9a8b18291786
node scripts/docs-audit/affected-docs.mjs --json b360cc7d5ca2e9cdb60a12018af20cbc3470cafa

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit f19475cSep 2, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14143-action-record-load-denied branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(runtime): tell an action handler when its caller-scope record load was refused - #14247

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied
Sep 2, 2026
Merged

fix(runtime): tell an action handler when its caller-scope record load was refused#14247
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14143

An action handler could not tell "the caller cannot read this row" from "this action legitimately has no record". This PR gives it a signal that can, additively, and writes it into the action-authoring docs — half the defect was that none of it was written down.

Not an exploitation claim. No exploitability work was done and none is claimed. This is a predicate defect: the platform's most natural authorization predicate for an action handler was structurally always-true. The isSystem: true elevation is settled design (#3914) and is untouched.

The mechanism, re-derived on the current tree

Anchors were taken by triage on 20b79bea; re-derived here on 66ecc50a and they have not drifted — packages/runtime/src/action-execution.ts still carries :1117 (the elevation), :1282/:1283 (the caller-scope load), :1284 (the swallow), :1285 (the comment) and :1288 (the stamp) at those exact lines.

:1282constgot: any=awaitcallData('get',{object: objectName,id: recordId},driver,envId,ec);
:1283if(got?.record)record=got.record;
:1284}catch{
:1285/* new-record / record-less actions pass an empty record */
:1288if(record&&(recordasany).id==null&&recordId)(recordasany).id=recordId;

A refused or empty load leaves record as {}, so record.id is exactly null — which is the stamp's own condition. The stamp condition and the load-failure condition coincide. The body then runs elevated, so authorization has to be re-established inside the handler, and:

if(!ctx.record?.id)returnrefuse();// always false — never refused anything

⛔ The stamp is not deleted. Record-less / new-record actions legitimately depend on it, and that dependency is pinned in this PR in both directions.

Measured first: can the caught error already tell refusal from absence?

No — and the collapse is deliberate, not an oversight. Traced end to end:

  • callData('get', ...) prefers protocol.getData, which calls engine.findOne under the caller's context and, when nothing comes back, throws recordNotFoundError (packages/metadata-protocol/src/protocol.ts);
  • its ObjectQL fallback throws the identical error, imported from the same producer for exactly that reason (packages/core/src/utils/record-not-found.ts: code: 'RECORD_NOT_FOUND', status: 404);
  • so a row filtered out by row-level security and an id that names nothing arrive at :1284 as the same error object shape. The call site's own comment already says it: "engages the same permission path as get_record — an unseen record reads as not-found". That is existence non-disclosure working as designed.

⇒ The repair is not smaller than the card assumed, and it deliberately is not an inspection of the caught error: there is nothing there to inspect. It is a separate channel. Consequences for the design, both taken:

  • the flag reports "the row did not resolve for this caller", not "the platform caught an authorization error";
  • it carries no code and no status, so nobody is invited to branch on a distinction the read path fuses. For an authorization decision the two are one answer: this caller has not demonstrated read access to that row.

What was built — direction 1 (additive), as ruled

ctx.recordLoadDeniedtrue exactly when a caller-scope load was attempted and did not deliver the row; absent, never false, otherwise. Same absence semantics as the referentialFieldClear marker already on this seam, so a handler reads ctx.recordLoadDenied === true.

if(ctx.recordLoadDenied){throwObject.assign(newError('Record not available'),{code: 'RECORD_NOT_FOUND'});}

Nothing that reaches a handler today stops reaching it. No existing key changes value. Direction 2 (pre-dispatch refusal) was not taken and no published accept set is narrowed.

Files, and why each one is required rather than scope creep

FileWhy
packages/runtime/src/action-execution.tsThe card's named site. New loadActionSubjectRecord — one producer for the load and its verdict — plus the signal on the MCP run_action context.
packages/runtime/src/domains/actions.tsThe REST /actions door carries the identical defect, byte-for-byte: git grep for the stamp returns exactly two hits, this one and the above. A documented guard that only one of two doors sets is an authorization guard silently inert on the other — the same defect one door over. Converted to call the same producer; no other behaviour changed.
packages/runtime/src/sandbox/script-runner.ts, body-runner.ts, quickjs-runner.tsThe sandbox ctx is a fixed key set — a key the dispatcher sets but the sandbox never marshals reads as undefined inside every inline body. Without these three edits the documented guard would be false on the surface an AI author writes most. Declared on ScriptContext, projected in buildActionSandboxContext, installed true-only in installCtx — the exact shape referentialFieldClear already uses two lines above.
content/docs/ui/actions.mdx, content/docs/automation/hook-bodies.mdxBinding requirement of the dispatch order, and the reporter's actual complaint. New "Authorization inside an action" section with the wrong guard, the right guard, and a three-row table; cross-referenced from the trusted-elevation callout and from the action-ctx paragraph in the hook-bodies reference.
packages/runtime/src/action-record-load-denied.test.ts, .changeset/action-record-load-denied-signal.mdVerification and release note.

packages/spec is not in the diff — the Clause ② path limb does not fire (the engine contract types executeAction's ctx as any, and no ActionContext schema exists in spec).

Verification

Everything below was run on 603d1236, this branch's head — the tree was clean and unchanged from that commit for the whole measurement.

Whole affected package, green:

pnpm --filter @objectstack/runtime test
Test Files 206 passed (206) Tests 3053 passed (3053)

Testspackages/runtime/src/action-record-load-denied.test.ts, 13 cases. The engine double is row-scoped on the one point that matters: find honours options.context.userId, so the row is returned to its owner and is invisible to anyone else — which is how row-level security actually manifests to callData('get', ...). The MCP leg is wired to the realcallData, so the real recordNotFoundError is what the dispatcher catches; nothing about the refused/absent collapse is mocked away.

pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 \
src/action-record-load-denied.test.ts src/action-ctx-user-shape.test.ts \
src/action-body-identity.test.ts src/http-dispatcher.actions-global-key.test.ts \
src/action-execution-calldata-not-found.test.ts
Test Files 5 passed (5) Tests 89 passed (89)

Both directions pinned, on both doors:

  • a caller who cannot read the row reaches the handler with recordLoadDenied === trueand ctx.record.id is still there, which is the prohibited regression, asserted rather than assumed;
  • the row owner reaches the handler with the real row and the key absent ('recordLoadDenied' in ctx === false);
  • an object-less action invoked with a recordId attempts no load and still gets the stamp;
  • a new-record invocation (no recordId) attempts no load, gets no flag, and no subject-row read is issued at all.

Every absence assertion has a firing positive control on the same rig: the identical expectation shape reports true for the unauthorized caller.

Reverse verification — three ablations, each on the committed tree, each proving the mutation landed on disk by occurrence counts (not by an editor's exit code) and each restoring under a trap whose success is proven by comparing git hash-object against the HEAD blob:

AblationResult
actionRecordLoadSignal returns {} (kills the signal at both doors)3 failed / 10 passed — both door tests and the producer test red; every stamp-regression and body test stayed green
the recordLoadDenied projection removed from buildActionSandboxContext1 failed / 12 passed — only the body-face test
vm.setProp(ctxObj, 'recordLoadDenied', ...) removed from installCtx1 failed / 12 passed — only the body-face test

No rebuild is needed for these: the test imports its subjects by relative specifier (./action-execution.js, ./sandbox/body-runner.js), which vite resolves to packages/runtime/src/*.ts — the package's dist is never on the path, and packages/runtime/vitest.config.ts declares no alias that touches a relative import.

Typecheckpnpm --filter @objectstack/runtime typecheck green, and measured rather than assumed: tsc --listFiles reports 5 of 5 edited source files inside that program. That config excludes **/*.test.ts, so the new test file was type-checked separately against the same compiler options with the exclusion lifted — also green.

Gates run locally (the farm runs in CI; these are the ones this diff implicates): check:nul-bytes · check:doc-anchors (295 fragment links resolve, including the two added here) · check:docs-single-h1 · check-system-context-census (109 elevation read sites, all anchored — this gate reads both edited dispatcher files by exact path) · check:route-envelope · check:doc-authoring · check:cross-package-test-inputs · check:test-source-alias · check-doc-frontmatter · check-doc-route-spelling · check-docs-section-name · check-keyed-text-bounds · check-comment-mask-adoption · check-undeclared-dep-imports · check:type-source-resolution · check:objectql-double-limit · check:where-matcher · check-empty-changeset · check-adr-0087-registration · check-changeset-no-major · check:changeset-gate-self-tests · check:objectui-changeset · check:docs-audit-scope · check:corpus-claim-drift · check:doc-security-posture · check:role-word · docs-audit/check-affected-docs · pm/check-half-states · check:published-files · check-ci-filter-parity — all green.

check-test-completeness exits 3 (PREREQUISITE NOT MET) with no argument: it grades a saved turbo run test log that only CI produces. Recorded as NOT MEASURED, per that script's own instruction.

Stop conditions checked

Out of scope, filed rather than fixed

The flow branch of the same two doors hands the same stamped record stub to AutomationContext, and the flow face has no counterpart signal. It is a narrower and conditional claim (a flow action is not system-elevated by default — the flow engine honours runAs, ADR-0049), and ruling it is a design question this card does not own. Filed unassigned as #14244.


Generated by Claude Code

…d was refused
Add `ctx.recordLoadDenied` — true exactly when a caller-scope subject-record
load was attempted and did not deliver the row, absent otherwise. Both action
doors (REST /actions and the MCP run_action bridge) now share one producer,
`loadActionSubjectRecord`, and the flag is marshalled explicitly into the body
sandbox so an inline body reads it as a registered handler does.
The `recordId` stamp is deliberately kept: record-less / new-record actions
depend on it, which is why the stamp condition and the load-failure condition
coincided and `if (!ctx.record?.id)` never refused anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))
What this run could not see
  • 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 — 23 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 b360cc7d5ca2e9cdb60a12018af20cbc3470cafapackageMentionDocs.

Which tree this was computed on

This run read content/docs from ea206df91049f4659826d26335bbe91463109f9c — the merge of head 603d12366c73f04ba6b38174be6e9a8b18291786 into base b360cc7d5ca2e9cdb60a12018af20cbc3470cafa, 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 ea206df91049f4659826d26335bbe91463109f9c && git checkout ea206df91049f4659826d26335bbe91463109f9c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b360cc7d5ca2e9cdb60a12018af20cbc3470cafa 603d12366c73f04ba6b38174be6e9a8b18291786 && git checkout -B drift-repro b360cc7d5ca2e9cdb60a12018af20cbc3470cafa && git merge --no-ff 603d12366c73f04ba6b38174be6e9a8b18291786
node scripts/docs-audit/affected-docs.mjs --json b360cc7d5ca2e9cdb60a12018af20cbc3470cafa

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit f19475cSep 2, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14143-action-record-load-denied branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): tell an action handler when its caller-scope record load was refused - #14247

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied
Sep 2, 2026
Merged

fix(runtime): tell an action handler when its caller-scope record load was refused#14247
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14143

An action handler could not tell "the caller cannot read this row" from "this action legitimately has no record". This PR gives it a signal that can, additively, and writes it into the action-authoring docs — half the defect was that none of it was written down.

Not an exploitation claim. No exploitability work was done and none is claimed. This is a predicate defect: the platform's most natural authorization predicate for an action handler was structurally always-true. The isSystem: true elevation is settled design (#3914) and is untouched.

The mechanism, re-derived on the current tree

Anchors were taken by triage on 20b79bea; re-derived here on 66ecc50a and they have not drifted — packages/runtime/src/action-execution.ts still carries :1117 (the elevation), :1282/:1283 (the caller-scope load), :1284 (the swallow), :1285 (the comment) and :1288 (the stamp) at those exact lines.

:1282constgot: any=awaitcallData('get',{object: objectName,id: recordId},driver,envId,ec);
:1283if(got?.record)record=got.record;
:1284}catch{
:1285/* new-record / record-less actions pass an empty record */
:1288if(record&&(recordasany).id==null&&recordId)(recordasany).id=recordId;

A refused or empty load leaves record as {}, so record.id is exactly null — which is the stamp's own condition. The stamp condition and the load-failure condition coincide. The body then runs elevated, so authorization has to be re-established inside the handler, and:

if(!ctx.record?.id)returnrefuse();// always false — never refused anything

⛔ The stamp is not deleted. Record-less / new-record actions legitimately depend on it, and that dependency is pinned in this PR in both directions.

Measured first: can the caught error already tell refusal from absence?

No — and the collapse is deliberate, not an oversight. Traced end to end:

  • callData('get', ...) prefers protocol.getData, which calls engine.findOne under the caller's context and, when nothing comes back, throws recordNotFoundError (packages/metadata-protocol/src/protocol.ts);
  • its ObjectQL fallback throws the identical error, imported from the same producer for exactly that reason (packages/core/src/utils/record-not-found.ts: code: 'RECORD_NOT_FOUND', status: 404);
  • so a row filtered out by row-level security and an id that names nothing arrive at :1284 as the same error object shape. The call site's own comment already says it: "engages the same permission path as get_record — an unseen record reads as not-found". That is existence non-disclosure working as designed.

⇒ The repair is not smaller than the card assumed, and it deliberately is not an inspection of the caught error: there is nothing there to inspect. It is a separate channel. Consequences for the design, both taken:

  • the flag reports "the row did not resolve for this caller", not "the platform caught an authorization error";
  • it carries no code and no status, so nobody is invited to branch on a distinction the read path fuses. For an authorization decision the two are one answer: this caller has not demonstrated read access to that row.

What was built — direction 1 (additive), as ruled

ctx.recordLoadDeniedtrue exactly when a caller-scope load was attempted and did not deliver the row; absent, never false, otherwise. Same absence semantics as the referentialFieldClear marker already on this seam, so a handler reads ctx.recordLoadDenied === true.

if(ctx.recordLoadDenied){throwObject.assign(newError('Record not available'),{code: 'RECORD_NOT_FOUND'});}

Nothing that reaches a handler today stops reaching it. No existing key changes value. Direction 2 (pre-dispatch refusal) was not taken and no published accept set is narrowed.

Files, and why each one is required rather than scope creep

FileWhy
packages/runtime/src/action-execution.tsThe card's named site. New loadActionSubjectRecord — one producer for the load and its verdict — plus the signal on the MCP run_action context.
packages/runtime/src/domains/actions.tsThe REST /actions door carries the identical defect, byte-for-byte: git grep for the stamp returns exactly two hits, this one and the above. A documented guard that only one of two doors sets is an authorization guard silently inert on the other — the same defect one door over. Converted to call the same producer; no other behaviour changed.
packages/runtime/src/sandbox/script-runner.ts, body-runner.ts, quickjs-runner.tsThe sandbox ctx is a fixed key set — a key the dispatcher sets but the sandbox never marshals reads as undefined inside every inline body. Without these three edits the documented guard would be false on the surface an AI author writes most. Declared on ScriptContext, projected in buildActionSandboxContext, installed true-only in installCtx — the exact shape referentialFieldClear already uses two lines above.
content/docs/ui/actions.mdx, content/docs/automation/hook-bodies.mdxBinding requirement of the dispatch order, and the reporter's actual complaint. New "Authorization inside an action" section with the wrong guard, the right guard, and a three-row table; cross-referenced from the trusted-elevation callout and from the action-ctx paragraph in the hook-bodies reference.
packages/runtime/src/action-record-load-denied.test.ts, .changeset/action-record-load-denied-signal.mdVerification and release note.

packages/spec is not in the diff — the Clause ② path limb does not fire (the engine contract types executeAction's ctx as any, and no ActionContext schema exists in spec).

Verification

Everything below was run on 603d1236, this branch's head — the tree was clean and unchanged from that commit for the whole measurement.

Whole affected package, green:

pnpm --filter @objectstack/runtime test
Test Files 206 passed (206) Tests 3053 passed (3053)

Testspackages/runtime/src/action-record-load-denied.test.ts, 13 cases. The engine double is row-scoped on the one point that matters: find honours options.context.userId, so the row is returned to its owner and is invisible to anyone else — which is how row-level security actually manifests to callData('get', ...). The MCP leg is wired to the realcallData, so the real recordNotFoundError is what the dispatcher catches; nothing about the refused/absent collapse is mocked away.

pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 \
src/action-record-load-denied.test.ts src/action-ctx-user-shape.test.ts \
src/action-body-identity.test.ts src/http-dispatcher.actions-global-key.test.ts \
src/action-execution-calldata-not-found.test.ts
Test Files 5 passed (5) Tests 89 passed (89)

Both directions pinned, on both doors:

  • a caller who cannot read the row reaches the handler with recordLoadDenied === trueand ctx.record.id is still there, which is the prohibited regression, asserted rather than assumed;
  • the row owner reaches the handler with the real row and the key absent ('recordLoadDenied' in ctx === false);
  • an object-less action invoked with a recordId attempts no load and still gets the stamp;
  • a new-record invocation (no recordId) attempts no load, gets no flag, and no subject-row read is issued at all.

Every absence assertion has a firing positive control on the same rig: the identical expectation shape reports true for the unauthorized caller.

Reverse verification — three ablations, each on the committed tree, each proving the mutation landed on disk by occurrence counts (not by an editor's exit code) and each restoring under a trap whose success is proven by comparing git hash-object against the HEAD blob:

AblationResult
actionRecordLoadSignal returns {} (kills the signal at both doors)3 failed / 10 passed — both door tests and the producer test red; every stamp-regression and body test stayed green
the recordLoadDenied projection removed from buildActionSandboxContext1 failed / 12 passed — only the body-face test
vm.setProp(ctxObj, 'recordLoadDenied', ...) removed from installCtx1 failed / 12 passed — only the body-face test

No rebuild is needed for these: the test imports its subjects by relative specifier (./action-execution.js, ./sandbox/body-runner.js), which vite resolves to packages/runtime/src/*.ts — the package's dist is never on the path, and packages/runtime/vitest.config.ts declares no alias that touches a relative import.

Typecheckpnpm --filter @objectstack/runtime typecheck green, and measured rather than assumed: tsc --listFiles reports 5 of 5 edited source files inside that program. That config excludes **/*.test.ts, so the new test file was type-checked separately against the same compiler options with the exclusion lifted — also green.

Gates run locally (the farm runs in CI; these are the ones this diff implicates): check:nul-bytes · check:doc-anchors (295 fragment links resolve, including the two added here) · check:docs-single-h1 · check-system-context-census (109 elevation read sites, all anchored — this gate reads both edited dispatcher files by exact path) · check:route-envelope · check:doc-authoring · check:cross-package-test-inputs · check:test-source-alias · check-doc-frontmatter · check-doc-route-spelling · check-docs-section-name · check-keyed-text-bounds · check-comment-mask-adoption · check-undeclared-dep-imports · check:type-source-resolution · check:objectql-double-limit · check:where-matcher · check-empty-changeset · check-adr-0087-registration · check-changeset-no-major · check:changeset-gate-self-tests · check:objectui-changeset · check:docs-audit-scope · check:corpus-claim-drift · check:doc-security-posture · check:role-word · docs-audit/check-affected-docs · pm/check-half-states · check:published-files · check-ci-filter-parity — all green.

check-test-completeness exits 3 (PREREQUISITE NOT MET) with no argument: it grades a saved turbo run test log that only CI produces. Recorded as NOT MEASURED, per that script's own instruction.

Stop conditions checked

Out of scope, filed rather than fixed

The flow branch of the same two doors hands the same stamped record stub to AutomationContext, and the flow face has no counterpart signal. It is a narrower and conditional claim (a flow action is not system-elevated by default — the flow engine honours runAs, ADR-0049), and ruling it is a design question this card does not own. Filed unassigned as #14244.


Generated by Claude Code

…d was refused
Add `ctx.recordLoadDenied` — true exactly when a caller-scope subject-record
load was attempted and did not deliver the row, absent otherwise. Both action
doors (REST /actions and the MCP run_action bridge) now share one producer,
`loadActionSubjectRecord`, and the flag is marshalled explicitly into the body
sandbox so an inline body reads it as a registered handler does.
The `recordId` stamp is deliberately kept: record-less / new-record actions
depend on it, which is why the stamp condition and the load-failure condition
coincided and `if (!ctx.record?.id)` never refused anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))
What this run could not see
  • 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 — 23 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 b360cc7d5ca2e9cdb60a12018af20cbc3470cafapackageMentionDocs.

Which tree this was computed on

This run read content/docs from ea206df91049f4659826d26335bbe91463109f9c — the merge of head 603d12366c73f04ba6b38174be6e9a8b18291786 into base b360cc7d5ca2e9cdb60a12018af20cbc3470cafa, 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 ea206df91049f4659826d26335bbe91463109f9c && git checkout ea206df91049f4659826d26335bbe91463109f9c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b360cc7d5ca2e9cdb60a12018af20cbc3470cafa 603d12366c73f04ba6b38174be6e9a8b18291786 && git checkout -B drift-repro b360cc7d5ca2e9cdb60a12018af20cbc3470cafa && git merge --no-ff 603d12366c73f04ba6b38174be6e9a8b18291786
node scripts/docs-audit/affected-docs.mjs --json b360cc7d5ca2e9cdb60a12018af20cbc3470cafa

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit f19475cSep 2, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14143-action-record-load-denied branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): tell an action handler when its caller-scope record load was refused - #14247

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied
Sep 2, 2026
Merged

fix(runtime): tell an action handler when its caller-scope record load was refused#14247
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14143

An action handler could not tell "the caller cannot read this row" from "this action legitimately has no record". This PR gives it a signal that can, additively, and writes it into the action-authoring docs — half the defect was that none of it was written down.

Not an exploitation claim. No exploitability work was done and none is claimed. This is a predicate defect: the platform's most natural authorization predicate for an action handler was structurally always-true. The isSystem: true elevation is settled design (#3914) and is untouched.

The mechanism, re-derived on the current tree

Anchors were taken by triage on 20b79bea; re-derived here on 66ecc50a and they have not drifted — packages/runtime/src/action-execution.ts still carries :1117 (the elevation), :1282/:1283 (the caller-scope load), :1284 (the swallow), :1285 (the comment) and :1288 (the stamp) at those exact lines.

:1282constgot: any=awaitcallData('get',{object: objectName,id: recordId},driver,envId,ec);
:1283if(got?.record)record=got.record;
:1284}catch{
:1285/* new-record / record-less actions pass an empty record */
:1288if(record&&(recordasany).id==null&&recordId)(recordasany).id=recordId;

A refused or empty load leaves record as {}, so record.id is exactly null — which is the stamp's own condition. The stamp condition and the load-failure condition coincide. The body then runs elevated, so authorization has to be re-established inside the handler, and:

if(!ctx.record?.id)returnrefuse();// always false — never refused anything

⛔ The stamp is not deleted. Record-less / new-record actions legitimately depend on it, and that dependency is pinned in this PR in both directions.

Measured first: can the caught error already tell refusal from absence?

No — and the collapse is deliberate, not an oversight. Traced end to end:

  • callData('get', ...) prefers protocol.getData, which calls engine.findOne under the caller's context and, when nothing comes back, throws recordNotFoundError (packages/metadata-protocol/src/protocol.ts);
  • its ObjectQL fallback throws the identical error, imported from the same producer for exactly that reason (packages/core/src/utils/record-not-found.ts: code: 'RECORD_NOT_FOUND', status: 404);
  • so a row filtered out by row-level security and an id that names nothing arrive at :1284 as the same error object shape. The call site's own comment already says it: "engages the same permission path as get_record — an unseen record reads as not-found". That is existence non-disclosure working as designed.

⇒ The repair is not smaller than the card assumed, and it deliberately is not an inspection of the caught error: there is nothing there to inspect. It is a separate channel. Consequences for the design, both taken:

  • the flag reports "the row did not resolve for this caller", not "the platform caught an authorization error";
  • it carries no code and no status, so nobody is invited to branch on a distinction the read path fuses. For an authorization decision the two are one answer: this caller has not demonstrated read access to that row.

What was built — direction 1 (additive), as ruled

ctx.recordLoadDeniedtrue exactly when a caller-scope load was attempted and did not deliver the row; absent, never false, otherwise. Same absence semantics as the referentialFieldClear marker already on this seam, so a handler reads ctx.recordLoadDenied === true.

if(ctx.recordLoadDenied){throwObject.assign(newError('Record not available'),{code: 'RECORD_NOT_FOUND'});}

Nothing that reaches a handler today stops reaching it. No existing key changes value. Direction 2 (pre-dispatch refusal) was not taken and no published accept set is narrowed.

Files, and why each one is required rather than scope creep

FileWhy
packages/runtime/src/action-execution.tsThe card's named site. New loadActionSubjectRecord — one producer for the load and its verdict — plus the signal on the MCP run_action context.
packages/runtime/src/domains/actions.tsThe REST /actions door carries the identical defect, byte-for-byte: git grep for the stamp returns exactly two hits, this one and the above. A documented guard that only one of two doors sets is an authorization guard silently inert on the other — the same defect one door over. Converted to call the same producer; no other behaviour changed.
packages/runtime/src/sandbox/script-runner.ts, body-runner.ts, quickjs-runner.tsThe sandbox ctx is a fixed key set — a key the dispatcher sets but the sandbox never marshals reads as undefined inside every inline body. Without these three edits the documented guard would be false on the surface an AI author writes most. Declared on ScriptContext, projected in buildActionSandboxContext, installed true-only in installCtx — the exact shape referentialFieldClear already uses two lines above.
content/docs/ui/actions.mdx, content/docs/automation/hook-bodies.mdxBinding requirement of the dispatch order, and the reporter's actual complaint. New "Authorization inside an action" section with the wrong guard, the right guard, and a three-row table; cross-referenced from the trusted-elevation callout and from the action-ctx paragraph in the hook-bodies reference.
packages/runtime/src/action-record-load-denied.test.ts, .changeset/action-record-load-denied-signal.mdVerification and release note.

packages/spec is not in the diff — the Clause ② path limb does not fire (the engine contract types executeAction's ctx as any, and no ActionContext schema exists in spec).

Verification

Everything below was run on 603d1236, this branch's head — the tree was clean and unchanged from that commit for the whole measurement.

Whole affected package, green:

pnpm --filter @objectstack/runtime test
Test Files 206 passed (206) Tests 3053 passed (3053)

Testspackages/runtime/src/action-record-load-denied.test.ts, 13 cases. The engine double is row-scoped on the one point that matters: find honours options.context.userId, so the row is returned to its owner and is invisible to anyone else — which is how row-level security actually manifests to callData('get', ...). The MCP leg is wired to the realcallData, so the real recordNotFoundError is what the dispatcher catches; nothing about the refused/absent collapse is mocked away.

pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 \
src/action-record-load-denied.test.ts src/action-ctx-user-shape.test.ts \
src/action-body-identity.test.ts src/http-dispatcher.actions-global-key.test.ts \
src/action-execution-calldata-not-found.test.ts
Test Files 5 passed (5) Tests 89 passed (89)

Both directions pinned, on both doors:

  • a caller who cannot read the row reaches the handler with recordLoadDenied === trueand ctx.record.id is still there, which is the prohibited regression, asserted rather than assumed;
  • the row owner reaches the handler with the real row and the key absent ('recordLoadDenied' in ctx === false);
  • an object-less action invoked with a recordId attempts no load and still gets the stamp;
  • a new-record invocation (no recordId) attempts no load, gets no flag, and no subject-row read is issued at all.

Every absence assertion has a firing positive control on the same rig: the identical expectation shape reports true for the unauthorized caller.

Reverse verification — three ablations, each on the committed tree, each proving the mutation landed on disk by occurrence counts (not by an editor's exit code) and each restoring under a trap whose success is proven by comparing git hash-object against the HEAD blob:

AblationResult
actionRecordLoadSignal returns {} (kills the signal at both doors)3 failed / 10 passed — both door tests and the producer test red; every stamp-regression and body test stayed green
the recordLoadDenied projection removed from buildActionSandboxContext1 failed / 12 passed — only the body-face test
vm.setProp(ctxObj, 'recordLoadDenied', ...) removed from installCtx1 failed / 12 passed — only the body-face test

No rebuild is needed for these: the test imports its subjects by relative specifier (./action-execution.js, ./sandbox/body-runner.js), which vite resolves to packages/runtime/src/*.ts — the package's dist is never on the path, and packages/runtime/vitest.config.ts declares no alias that touches a relative import.

Typecheckpnpm --filter @objectstack/runtime typecheck green, and measured rather than assumed: tsc --listFiles reports 5 of 5 edited source files inside that program. That config excludes **/*.test.ts, so the new test file was type-checked separately against the same compiler options with the exclusion lifted — also green.

Gates run locally (the farm runs in CI; these are the ones this diff implicates): check:nul-bytes · check:doc-anchors (295 fragment links resolve, including the two added here) · check:docs-single-h1 · check-system-context-census (109 elevation read sites, all anchored — this gate reads both edited dispatcher files by exact path) · check:route-envelope · check:doc-authoring · check:cross-package-test-inputs · check:test-source-alias · check-doc-frontmatter · check-doc-route-spelling · check-docs-section-name · check-keyed-text-bounds · check-comment-mask-adoption · check-undeclared-dep-imports · check:type-source-resolution · check:objectql-double-limit · check:where-matcher · check-empty-changeset · check-adr-0087-registration · check-changeset-no-major · check:changeset-gate-self-tests · check:objectui-changeset · check:docs-audit-scope · check:corpus-claim-drift · check:doc-security-posture · check:role-word · docs-audit/check-affected-docs · pm/check-half-states · check:published-files · check-ci-filter-parity — all green.

check-test-completeness exits 3 (PREREQUISITE NOT MET) with no argument: it grades a saved turbo run test log that only CI produces. Recorded as NOT MEASURED, per that script's own instruction.

Stop conditions checked

Out of scope, filed rather than fixed

The flow branch of the same two doors hands the same stamped record stub to AutomationContext, and the flow face has no counterpart signal. It is a narrower and conditional claim (a flow action is not system-elevated by default — the flow engine honours runAs, ADR-0049), and ruling it is a design question this card does not own. Filed unassigned as #14244.


Generated by Claude Code

…d was refused
Add `ctx.recordLoadDenied` — true exactly when a caller-scope subject-record
load was attempted and did not deliver the row, absent otherwise. Both action
doors (REST /actions and the MCP run_action bridge) now share one producer,
`loadActionSubjectRecord`, and the flag is marshalled explicitly into the body
sandbox so an inline body reads it as a registered handler does.
The `recordId` stamp is deliberately kept: record-less / new-record actions
depend on it, which is why the stamp condition and the load-failure condition
coincided and `if (!ctx.record?.id)` never refused anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))
What this run could not see
  • 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 — 23 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 b360cc7d5ca2e9cdb60a12018af20cbc3470cafapackageMentionDocs.

Which tree this was computed on

This run read content/docs from ea206df91049f4659826d26335bbe91463109f9c — the merge of head 603d12366c73f04ba6b38174be6e9a8b18291786 into base b360cc7d5ca2e9cdb60a12018af20cbc3470cafa, 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 ea206df91049f4659826d26335bbe91463109f9c && git checkout ea206df91049f4659826d26335bbe91463109f9c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b360cc7d5ca2e9cdb60a12018af20cbc3470cafa 603d12366c73f04ba6b38174be6e9a8b18291786 && git checkout -B drift-repro b360cc7d5ca2e9cdb60a12018af20cbc3470cafa && git merge --no-ff 603d12366c73f04ba6b38174be6e9a8b18291786
node scripts/docs-audit/affected-docs.mjs --json b360cc7d5ca2e9cdb60a12018af20cbc3470cafa

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit f19475cSep 2, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14143-action-record-load-denied branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(runtime): tell an action handler when its caller-scope record load was refused - #14247

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied
Sep 2, 2026
Merged

fix(runtime): tell an action handler when its caller-scope record load was refused#14247
os-support-ai merged 1 commit into
mainfrom
claude/issue-14143-action-record-load-denied

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14143

An action handler could not tell "the caller cannot read this row" from "this action legitimately has no record". This PR gives it a signal that can, additively, and writes it into the action-authoring docs — half the defect was that none of it was written down.

Not an exploitation claim. No exploitability work was done and none is claimed. This is a predicate defect: the platform's most natural authorization predicate for an action handler was structurally always-true. The isSystem: true elevation is settled design (#3914) and is untouched.

The mechanism, re-derived on the current tree

Anchors were taken by triage on 20b79bea; re-derived here on 66ecc50a and they have not drifted — packages/runtime/src/action-execution.ts still carries :1117 (the elevation), :1282/:1283 (the caller-scope load), :1284 (the swallow), :1285 (the comment) and :1288 (the stamp) at those exact lines.

:1282constgot: any=awaitcallData('get',{object: objectName,id: recordId},driver,envId,ec);
:1283if(got?.record)record=got.record;
:1284}catch{
:1285/* new-record / record-less actions pass an empty record */
:1288if(record&&(recordasany).id==null&&recordId)(recordasany).id=recordId;

A refused or empty load leaves record as {}, so record.id is exactly null — which is the stamp's own condition. The stamp condition and the load-failure condition coincide. The body then runs elevated, so authorization has to be re-established inside the handler, and:

if(!ctx.record?.id)returnrefuse();// always false — never refused anything

⛔ The stamp is not deleted. Record-less / new-record actions legitimately depend on it, and that dependency is pinned in this PR in both directions.

Measured first: can the caught error already tell refusal from absence?

No — and the collapse is deliberate, not an oversight. Traced end to end:

  • callData('get', ...) prefers protocol.getData, which calls engine.findOne under the caller's context and, when nothing comes back, throws recordNotFoundError (packages/metadata-protocol/src/protocol.ts);
  • its ObjectQL fallback throws the identical error, imported from the same producer for exactly that reason (packages/core/src/utils/record-not-found.ts: code: 'RECORD_NOT_FOUND', status: 404);
  • so a row filtered out by row-level security and an id that names nothing arrive at :1284 as the same error object shape. The call site's own comment already says it: "engages the same permission path as get_record — an unseen record reads as not-found". That is existence non-disclosure working as designed.

⇒ The repair is not smaller than the card assumed, and it deliberately is not an inspection of the caught error: there is nothing there to inspect. It is a separate channel. Consequences for the design, both taken:

  • the flag reports "the row did not resolve for this caller", not "the platform caught an authorization error";
  • it carries no code and no status, so nobody is invited to branch on a distinction the read path fuses. For an authorization decision the two are one answer: this caller has not demonstrated read access to that row.

What was built — direction 1 (additive), as ruled

ctx.recordLoadDeniedtrue exactly when a caller-scope load was attempted and did not deliver the row; absent, never false, otherwise. Same absence semantics as the referentialFieldClear marker already on this seam, so a handler reads ctx.recordLoadDenied === true.

if(ctx.recordLoadDenied){throwObject.assign(newError('Record not available'),{code: 'RECORD_NOT_FOUND'});}

Nothing that reaches a handler today stops reaching it. No existing key changes value. Direction 2 (pre-dispatch refusal) was not taken and no published accept set is narrowed.

Files, and why each one is required rather than scope creep

FileWhy
packages/runtime/src/action-execution.tsThe card's named site. New loadActionSubjectRecord — one producer for the load and its verdict — plus the signal on the MCP run_action context.
packages/runtime/src/domains/actions.tsThe REST /actions door carries the identical defect, byte-for-byte: git grep for the stamp returns exactly two hits, this one and the above. A documented guard that only one of two doors sets is an authorization guard silently inert on the other — the same defect one door over. Converted to call the same producer; no other behaviour changed.
packages/runtime/src/sandbox/script-runner.ts, body-runner.ts, quickjs-runner.tsThe sandbox ctx is a fixed key set — a key the dispatcher sets but the sandbox never marshals reads as undefined inside every inline body. Without these three edits the documented guard would be false on the surface an AI author writes most. Declared on ScriptContext, projected in buildActionSandboxContext, installed true-only in installCtx — the exact shape referentialFieldClear already uses two lines above.
content/docs/ui/actions.mdx, content/docs/automation/hook-bodies.mdxBinding requirement of the dispatch order, and the reporter's actual complaint. New "Authorization inside an action" section with the wrong guard, the right guard, and a three-row table; cross-referenced from the trusted-elevation callout and from the action-ctx paragraph in the hook-bodies reference.
packages/runtime/src/action-record-load-denied.test.ts, .changeset/action-record-load-denied-signal.mdVerification and release note.

packages/spec is not in the diff — the Clause ② path limb does not fire (the engine contract types executeAction's ctx as any, and no ActionContext schema exists in spec).

Verification

Everything below was run on 603d1236, this branch's head — the tree was clean and unchanged from that commit for the whole measurement.

Whole affected package, green:

pnpm --filter @objectstack/runtime test
Test Files 206 passed (206) Tests 3053 passed (3053)

Testspackages/runtime/src/action-record-load-denied.test.ts, 13 cases. The engine double is row-scoped on the one point that matters: find honours options.context.userId, so the row is returned to its owner and is invisible to anyone else — which is how row-level security actually manifests to callData('get', ...). The MCP leg is wired to the realcallData, so the real recordNotFoundError is what the dispatcher catches; nothing about the refused/absent collapse is mocked away.

pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 \
src/action-record-load-denied.test.ts src/action-ctx-user-shape.test.ts \
src/action-body-identity.test.ts src/http-dispatcher.actions-global-key.test.ts \
src/action-execution-calldata-not-found.test.ts
Test Files 5 passed (5) Tests 89 passed (89)

Both directions pinned, on both doors:

  • a caller who cannot read the row reaches the handler with recordLoadDenied === trueand ctx.record.id is still there, which is the prohibited regression, asserted rather than assumed;
  • the row owner reaches the handler with the real row and the key absent ('recordLoadDenied' in ctx === false);
  • an object-less action invoked with a recordId attempts no load and still gets the stamp;
  • a new-record invocation (no recordId) attempts no load, gets no flag, and no subject-row read is issued at all.

Every absence assertion has a firing positive control on the same rig: the identical expectation shape reports true for the unauthorized caller.

Reverse verification — three ablations, each on the committed tree, each proving the mutation landed on disk by occurrence counts (not by an editor's exit code) and each restoring under a trap whose success is proven by comparing git hash-object against the HEAD blob:

AblationResult
actionRecordLoadSignal returns {} (kills the signal at both doors)3 failed / 10 passed — both door tests and the producer test red; every stamp-regression and body test stayed green
the recordLoadDenied projection removed from buildActionSandboxContext1 failed / 12 passed — only the body-face test
vm.setProp(ctxObj, 'recordLoadDenied', ...) removed from installCtx1 failed / 12 passed — only the body-face test

No rebuild is needed for these: the test imports its subjects by relative specifier (./action-execution.js, ./sandbox/body-runner.js), which vite resolves to packages/runtime/src/*.ts — the package's dist is never on the path, and packages/runtime/vitest.config.ts declares no alias that touches a relative import.

Typecheckpnpm --filter @objectstack/runtime typecheck green, and measured rather than assumed: tsc --listFiles reports 5 of 5 edited source files inside that program. That config excludes **/*.test.ts, so the new test file was type-checked separately against the same compiler options with the exclusion lifted — also green.

Gates run locally (the farm runs in CI; these are the ones this diff implicates): check:nul-bytes · check:doc-anchors (295 fragment links resolve, including the two added here) · check:docs-single-h1 · check-system-context-census (109 elevation read sites, all anchored — this gate reads both edited dispatcher files by exact path) · check:route-envelope · check:doc-authoring · check:cross-package-test-inputs · check:test-source-alias · check-doc-frontmatter · check-doc-route-spelling · check-docs-section-name · check-keyed-text-bounds · check-comment-mask-adoption · check-undeclared-dep-imports · check:type-source-resolution · check:objectql-double-limit · check:where-matcher · check-empty-changeset · check-adr-0087-registration · check-changeset-no-major · check:changeset-gate-self-tests · check:objectui-changeset · check:docs-audit-scope · check:corpus-claim-drift · check:doc-security-posture · check:role-word · docs-audit/check-affected-docs · pm/check-half-states · check:published-files · check-ci-filter-parity — all green.

check-test-completeness exits 3 (PREREQUISITE NOT MET) with no argument: it grades a saved turbo run test log that only CI produces. Recorded as NOT MEASURED, per that script's own instruction.

Stop conditions checked

Out of scope, filed rather than fixed

The flow branch of the same two doors hands the same stamped record stub to AutomationContext, and the flow face has no counterpart signal. It is a narrower and conditional claim (a flow action is not system-elevated by default — the flow engine honours runAs, ADR-0049), and ruling it is a design question this card does not own. Filed unassigned as #14244.


Generated by Claude Code

…d was refused
Add `ctx.recordLoadDenied` — true exactly when a caller-scope subject-record
load was attempted and did not deliver the row, absent otherwise. Both action
doors (REST /actions and the MCP run_action bridge) now share one producer,
`loadActionSubjectRecord`, and the flag is marshalled explicitly into the body
sandbox so an inline body reads it as a registered handler does.
The `recordId` stamp is deliberately kept: record-less / new-record actions
depend on it, which is why the stamp condition and the load-failure condition
coincided and `if (!ctx.record?.id)` never refused anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))
What this run could not see
  • 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 — 23 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 b360cc7d5ca2e9cdb60a12018af20cbc3470cafapackageMentionDocs.

Which tree this was computed on

This run read content/docs from ea206df91049f4659826d26335bbe91463109f9c — the merge of head 603d12366c73f04ba6b38174be6e9a8b18291786 into base b360cc7d5ca2e9cdb60a12018af20cbc3470cafa, 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 ea206df91049f4659826d26335bbe91463109f9c && git checkout ea206df91049f4659826d26335bbe91463109f9c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b360cc7d5ca2e9cdb60a12018af20cbc3470cafa 603d12366c73f04ba6b38174be6e9a8b18291786 && git checkout -B drift-repro b360cc7d5ca2e9cdb60a12018af20cbc3470cafa && git merge --no-ff 603d12366c73f04ba6b38174be6e9a8b18291786
node scripts/docs-audit/affected-docs.mjs --json b360cc7d5ca2e9cdb60a12018af20cbc3470cafa

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit f19475cSep 2, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14143-action-record-load-denied branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude