fix(runtime): consume the caller-scope load verdict at the flow and script action doors - #16854
Conversation
…cript action doors `loadActionSubjectRecord` computes one `recordLoadDenied` verdict for every action door, and exactly one door consumed it as a refusal — the declarative update. The flow door and the script/body door spread it into the context as a field and proceeded, so MCP `run_action` on a `type: 'flow'` action answered `ok: true` and started a persisted run for a `recordId` the caller cannot read, and identically for an id that names nothing at all. Both remaining doors now consume the verdict on both surfaces (REST `/actions` and the MCP `run_action` bridge) through one shared refusal, `refuseDeniedSubjectLoad`, placed before the automation run is created and before a trusted body is entered. The envelope is the shared not-found one (`RECORD_NOT_FOUND`, 404), so an unreadable row and an id that names nothing stay one answer. Record-less and new-record actions never attempt a load, so their verdict can never be `true` and their stamp behaviour is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Nine assertions across six files dispatched a row-scoped action with a `recordId` against a double that answered every read with `[]`, so the caller-scope subject load never delivered and the door now refuses. Four rigs answer the by-id pre-load instead — their subject is the elevated `ctx.api` binding, the mounted route's auth gate, handler-key addressing and flow dispatch, none of which is the load. Two fixtures pinned the branch this card closes and are re-pinned: a degraded engine with no `find` now fails closed on a row-scoped call (404, never a 500) and still runs the record-less one, and the flow route's "seeds recordId even when the record never loaded" case is split into the refusal it now is plus the new-record invocation it conflated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…ow-door-record-load-denied
…o the type-check debt ledger does not grow Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 98b992484aae6d037f757da02eba88561a3f382d && git checkout 98b992484aae6d037f757da02eba88561a3f382d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a814bdb859dfe707346bcb7df9a2c153c00b6404 2aaad3e517fcd37631507c16dcc0bf0880fb434d && git checkout -B drift-repro a814bdb859dfe707346bcb7df9a2c153c00b6404 && git merge --no-ff 2aaad3e517fcd37631507c16dcc0bf0880fb434d
node scripts/docs-audit/affected-docs.mjs --json a814bdb859dfe707346bcb7df9a2c153c00b6404
|
Accepted by the ⭐ The grading held, and it was measured rather than assumedThis seat kept the card at p1 rather than p0 on one property: the door is not an existence oracle — step 4 of the report shows ⇒ It was re-driven, and it holds: the new pin carries an envelope-equality assertion for exactly this, among 42 assertions. p1 stands. ⭐ And the ablation reproduces the card verbatim — Gates
⭐ The predicate is stricter than the acceptance asked for — and the reason is rightAcceptance item 3 said "refuse only for actions that are NOT record-less". The delivery did ⛔ not implement that via
It keyed on ⭐ One refusal, not three copiesA shared ⛔ The collateral test churn is an INVERSION, not a weakening — audited6 pre-existing test files modified. ⛔ No
Added in their place: +10 ⭐ Two pins it added that I did not ask for and should have:
|
Fixes #16370
Clause-②: no
The defect
packages/runtime/src/action-execution.tscomputes one caller-scope load verdict —recordLoadDenied, from the single producerloadActionSubjectRecord— for every action door. Exactly one door consumed it as a refusal: the declarative update (#15079,contract point 3). The flow door and the script/body door spread the same verdict into the context as a field and proceeded.So MCP
run_actionon atype: 'flow'action answeredok: trueand started a persisted run for arecordIdthe caller cannot read — and, identically, for an id that names nothing at all — whileget_recordanswered "not found" andupdate_recordanswered "no access" for that same id in the same session. Nothing in the response told the calling agent the row had not been delivered.#15168 carried the verdict into the flow context and said in as many words that "whether the automation engine acts on it (a flow-level refusal, a step condition) is a separate reading". This PR is that reading.
What changed
One shared refusal,
refuseDeniedSubjectLoad(objectName, recordId, subject), inaction-execution.tsbeside the producer that computes the verdict. Three doors call it, on both surfaces:run_actionbridge/actionsrouteexecuteDeclarativeUpdateAction— converged onto the shared function, behaviour unchanged to the bytecontract point 3block ininvokeBusinessActioncontract point 3block indomains/actions.tsPlacement is load-bearing in both directions. It sits after the declarative branch, whose own points 7 and 4 answer a located
400before point 3 ever runs — hoisting the refusal over them would re-label those prescriptions as a404. And it sits before the identity resolution and everything below it, so no persisted automation run exists and no trusted, RLS/FLS-bypassing body has been entered when the refusal lands.验收备注
1. ⭐ Mirrors the shape of the existing
contract point 3block, comment convention includedBoth new blocks open with the file's own marker,
// ── contract point 3: the caller-scope load's VERDICT, consumed ──, and the existing declarative block keeps it while delegating to the shared function. Three doors, one rule, one implementation, three visible contract points — which is the part that makes the next reader able to see there are three doors at all.2.⚠️ The script/body door is fixed in the same PR as the flow door — and so is the REST surface
The card names two doors; the file's own comment at the shared producer's MCP call site names two surfaces ("this door and the REST
/actionsdoor emit the samerecordLoadDeniedsignal to handlers"). Both readings are honoured: the refusal is called at the flow door and the script/body door, on the MCP bridge and on the REST route.packages/runtime/src/domains/actions.tsis therefore edited too — one file outside the claim's declared surface. Re-scanned before editing: 22 open PRs, full pagination (6 pages for the Version Packages PR), 687 file rows, 673 distinct files →packages/runtime/src/domains/actions.ts0 holders,packages/runtime/src/action-execution.ts0 holders. Positive control fired on the same scan:packages/runtime/src/dispatcher-error-vocabulary.ts2 holders (#16827, #16730),packages/runtime/src/domains/automation.ts1 holder (#16755).Leaving the REST script door open would have re-created the exact failure class #14143 and #15168 each already paid for on this seam.
3. Record-less and new-record actions are unchanged — by construction, not by a second test
The predicate is
subject.recordLoadDenied, which the producer sets only when a caller-scope load was attempted: arecordIdwas supplied and the action key is not object-less. A record-less (object-less key) action and a new-record (norecordId) invocation therefore never reach the refusal, and theirrecordIdstamp onctx.recordis byte-for-byte what it was.⛔ Deliberately not
summarizeAction'srequiresRecord, the other spelling the card offers: that value is derived fromlocations, optional metadata an author may omit entirely — which is exactly whycontract point 7one function up refuses to be left to it. An authorization refusal keyed on optional metadata is absent for precisely the authors who declared the least, andschedule_followup-shaped actions that declare nolocationswould have kept the hole.4. ⛔ The shared not-found envelope, never an existence-disclosing refusal
recordNotFoundError(objectName, recordId)—RECORD_NOT_FOUND,404, the same factory the read path and the declarative door already answer. Not a403, not a new "denied" code. Pinned as an envelope equality, not as two separate status assertions: an unreadable row and an id that names nothing produce the samestatus, the samecodeand the same message with the id normalised out.5. ⛔ No MCP resume verb
Nothing in this diff touches the MCP tool set. #15705's held item is untouched.
6. The report's steps 3 and 4, re-run
5e08628f,@objectstack/{mcp,runtime,service-automation}17.2.0). They were reproduced as executable pins over the real REST dispatcher and the real MCPrun_actionbridge, wired to the realcallData, against a row-scoped engine double whose row is visible to its owner and to nobody else.The pre-fix reading is the ablation: the fix was committed first, then
refuseDeniedSubjectLoadwas neutralised on disk (injected marker proven present bygrep -c, one occurrence) and the pins re-run. 17 of 42 turned red, and the failure text is the card's step 4 verbatim:Restore was proven byte-identical, not assumed:
git checkout HEAD -- ...thengit hash-objectequal to theHEADblobbcf429e0fdf055f184fc413ac0efb06333200105, withgit diff HEADandgit status --porcelainboth empty.⭐ The measurement does NOT contradict the p1 grading — it confirms it. The door was not an existence oracle before the fix (step 3 and step 4 produced the identical
ok: trueshape) and is not one after it (both produce the identicalRECORD_NOT_FOUND/404). That is asserted directly, as a set, on both doors and both surfaces.Coverage
Flow door and script/body door × (denied load · nonexistent id · a record-less action unchanged · a load that succeeds), each on the MCP bridge and the REST route —
packages/runtime/src/action-door-record-load-denied.test.ts, 42 assertions.Fixtures the change moved, and how each was triaged
Nine assertions in six pre-existing files dispatched a row-scoped action with a
recordIdagainst a double that answered every read with[]. Two dispositions, no test skipped, disabled or quarantined:action-body-identity, the mounted-routeanonymous-gateintegration boot,http-dispatcher.actions-identity-addressingand the MCP flow bridge inhttp-dispatcher.test.ts. Their subject is the elevatedctx.apibinding, the auth gate on the mounted route, handler-key addressing and flow dispatch. None of them is the load, and an unrelated404would have hidden the very200they exist to prove.action-ctx-user-shape's "an engine with nofindat all does not break the dispatch" now runs without arecordId(keeping its actual subject, the quiet name-resolution fallback) and gains a sibling pinning that a row-scoped call on that degraded engine fails closed —404, never a500.http-dispatcher.actions-type-dispatch's "seedsrecordIdfrom the URL even when the record never loaded" conflated two shapes: it is split into the refusal the unreadable case now is, plus the new-record invocation, which still dispatches with nothing to seed and nothing to refuse.action-record-load-denied.test.tskeeps every producer, stamp, absence-convention and sandbox-face pin. Its four door-level "denied" cases assert the refusal, and runtime: carry the caller-scope record-load signal into a flow action's context — dispatchFlowAction spreads actionRecordLoadSignal on both doors (the runtime half of #14244) #15168's flow-context assertions moved ontodispatchFlowActionitself, where a denied subject is still constructible — so that contract is re-pinned where it is reachable rather than deleted.Verification, at
2aaad3e517pnpm --filter '@objectstack/runtime^...' build— closure built (VERDICT command-exit 0), re-built after mergingorigin/main(acf4d38336).pnpm --filter @objectstack/runtime test— 243 files, 3391 tests, all passing.pnpm --filter @objectstack/runtime test:repo— 2 files, 69 tests passing.pnpm --filter @objectstack/runtime typecheck— green, includingcheck:test-typecheck; the type-check debt ledger is not raised (the two new dispatch responses bind through a local).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackfrom the merge-base change set, then reconciled with--ran: 57 derived, 57 run, 0 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loadsandcheck:type-check-debtfirst answeredPREREQUISITE NOT MET(exit 3, not a finding); both were re-run green after a fullturbo run buildover./packages/*and./packages/*/*(72 tasks successful).eslint . --no-inline-config --format jsonover the whole repository: 6359 files, 0 errors, 0 warnings — the union, not a narrowing. The config declares noparserOptions.projectand no typed@typescript-eslintrules, so this reading is not type-aware.Noted, not filed
After this change the
truearm ofrecordLoadDeniedis no longer reachable through these two doors — the platform refuses first. The key stays declared (AutomationContext.recordLoadDenied,packages/spec/src/contracts/automation-service.ts, with its own pin test) and still populated bydispatchFlowAction, so an author guard written against it keeps working and any future caller of the dispatcher that legitimately does not refuse still gets the signal. That is defence in depth with the outer layer closed, not dead metadata — but it is the honest reading, and the card that surfaces the verdict to an MCP caller (the card's expectation 2, deliberately not in this PR) is the natural place to revisit it.🤖 Generated with Claude Code
https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Generated by Claude Code