Repository navigation
finding: a flow-type action's AutomationContext gets the same stamped record stub when the caller cannot read the row — and the flow face has no recordLoadDenied #14244
Description
Activity
Triage (R+89, triage seat, session
session_019kDRpB7D2XzVzkaLp57T5D): graded →pm:blocked·priority:p2·security(topic marker, no exploit asserted) ·domain:spec· type Feature.findingremoved.Blocked-by: #14143
The design question is answered by inheritance, not reopened. #14143's dispatch ruled the handler face: direction 1 (additive signal that the caller-scope load was refused) is the route; direction 2 (refuse before dispatch, i.e. narrowing a published path) is not for a lane to take. The flow face is the same mechanism one surface over — the same
loadActionSubjectRecordproducer, the same stamped stub — so it inherits the ruling with its reason: an additive key on the flow run's context that mirrors the handler face's signal, so arunAs: 'system'flow has something to guard on; ⛔ no narrowing ofdispatchFlowAction, ⛔ no claim thatrunAs: 'system'is wrong. If the implementer finds a real semantic difference (e.g. the only viable fix is to refuse the start), that is a fork report back to the decision inbox, not a unilateral choice.Why
domain:specand why blocked:AutomationContextis declared inpackages/spec/src/contracts/automation-service.ts(single owner), so adding the key expands a published contract surface — Clause-②: yes at dispatch (contract-review tier). And the key must carry the producer's denial signal, which #14143 (PR #14247, in flight) introduces; landing this first would invent a second spelling. The unlock scan returns it topm:queuewhen #14143 closes; the spec seat then names the key in the same spelling the handler face shipped.
Generated by Claude Code
- addedpriority:p2Medium: important, M3Medium: important, M3and removed
on Sep 2, 2026 Unlock scan (R+90, triage seat, session
session_019kDRpB7D2XzVzkaLp57T5D): released →pm:queue. Blocker #14143 CLOSEDcompletedat 01:43:22Z via merged PR #14247 ("tell an action handler when its caller-scope record load was refused") — direction 1, the additive signal, as its dispatch ruled.Card face re-verified on the merged ref (
origin/main=72adb7f), and this is the reading that matters — the producer's spelling now exists, and the flow face still does not carry it:reading handler / sandbox face recordLoadDeniedbuilt inaction-execution.ts(:1240–:1258) and exposed to a body throughactionRecordLoadSignal(:1267) ✅flow face dispatchFlowActionis still called at:1396with the plain{ objectName, record, params, recordId, ec, envId }— no load signalthe contract AutomationContext(packages/spec/src/contracts/automation-service.ts) declaresrecord/previous/object/event/userId/positions/permissions/tenantId… and norecordLoadDenied⇒ holds⇒ Exactly the condition this card was parked for: the key must mirror the producer's spelling rather than invent a second one, and that spelling is now on
main. Everything else in the triage stands — the ruling is inherited, not reopened (additive key, ⛔ no narrowing ofdispatchFlowAction, ⛔ no claim thatrunAs: 'system'is wrong; a measured need to refuse the start is a fork report to the decision inbox). Clause-②: yes —AutomationContextis a published spec contract, so the spec seat dispatches at the contract-review tier.
Generated by Claude Code
Claim: PM loop,
domain:specseat (seat post #6017), R6 of this shift — the SPEC half of the inherited ruling (triage 5502970354 + unlock scan 5503608656, 2026-09-02):AutomationContext(packages/spec/src/contracts/automation-service.ts) gains the additive key that mirrors the handler face's producer spelling,recordLoadDenied?: true(the exact shapeactionRecordLoadSignalinpackages/runtime/src/action-execution.ts:1278emits), so arunAs: 'system'flow has something to guard on. ⛔ No narrowing ofdispatchFlowAction, ⛔ no claim thatrunAs: 'system'is wrong; a measured need to refuse the start is a fork report to the decision inbox. The runtime half —dispatchFlowAction(action-execution.ts:1396) and the REST/actionsdoor passing the signal into the run's context — is adomain:clifollow-up card this seat files at ACCEPT from the dev's read-and-report,Blocked-by:this card.
Session:session_0174WZTU6XcFcS7g2kykC53i(GitHubzhuangjianguo)
Branch:claude/issue-14244-automation-context-record-load-denied
Claimed at 2026-09-04T02:32Z; queue read 00:40Z (next by age). Slot check: 3-cap, 2 devs running (#14168, #14180) before this claim.
File surface (spec only):packages/spec/src/contracts/automation-service.ts(the key + TSDoc), its pins beside the family's existing tests (a type-level pin that the key's shape equals the producer's{ recordLoadDenied?: true }), generated api-surface / export-origins / docs, the hand-written docs page(s) namingAutomationContext(affected-docs), a@objectstack/specchangeset (minor, additive). ⛔packages/runtime/**,packages/services/**, objectui,content/docs/releases/**.
Clause ② yes (a published contract surface widens) —needs:contract-reviewhung on this card in this stroke; the dev hangs it on the PR. Contract review at tier by this seat. Pre-dispatch readings (2026-09-04T02:32Z):recordLoadDeniedexists only inpackages/runtime/src/action-execution.ts(:1203–:1279) and its test onorigin/main2cc46103;AutomationContextdeclares no such key (the unlock scan's reading holds); 0 of 24 open PRs touchautomation-service.ts(PR #11336 touches only a changeset aboutAutomationContext.flowNameprose); H17 on-hold trigger-file index: no hit.
Generated by Claude Code
Dispatch (R6 of this shift, 2026-09-04T02:34Z) —
domain:specseat,session_0174WZTU6XcFcS7g2kykC53i, seat post #6017.mode:subagent,model: fable(CONTRACT_REVIEW_TIER), size S, Clause ② yes (dual carrier:needs:contract-reviewhung on this card at claim; the dev hangs it on the PR). The dev leaves its ownClaim:comment below carryingClause-②: yesand its file surface before its first edit.Scope handed to the dev — the SPEC half of the inherited ruling (triage 5502970354, unlock scan 5503608656), contract-first:
AutomationContextgainsrecordLoadDenied?: true— exactly the producer's shape (actionRecordLoadSignalinpackages/runtime/src/action-execution.ts:1278), with a TSDoc that says who sets it, what arunAs: 'system'flow guards on, and that the flow face does not populate it until the runtime half lands; type-level pins (true | undefined, additive positive control,@ts-expect-erroronfalse); generated products; hand-written docs naming the context; changeset@objectstack/specminor (additive, no ADR-0087 marker owed). ⛔packages/runtime/**untouched — the wiring ofdispatchFlowAction(:1396) and the REST/actionsdoor is thedomain:clifollow-up card this seat files at ACCEPT from the dev's read-and-report (Blocked-by: #14244). ⛔ No narrowing ofdispatchFlowAction, ⛔ no claim thatrunAs: 'system'is wrong; a measured need to refuse the start is a fork report to the decision inbox. ACCEPT path: report → fuse read → contract review at tier → landing at all-green.
Generated by Claude Code
Claim: os-dev subagent for the SPEC half (dispatched by the
domain:specPM seat, seat post #6017, PM claim 5534838529).
Session:session_0174WZTU6XcFcS7g2kykC53i(GitHubzhuangjianguo— assignee is the PM's, untouched)
Branch:claude/issue-14244-automation-context-record-load-denied(pushed empty offorigin/main2cc46103as the write-route probe; worktree../objectstack-issue-14244)
Clause-②: yesFile surface (matches the PM claim; delta stated where it applies):
packages/spec/src/contracts/automation-service.ts—AutomationContextgains the additive keyrecordLoadDenied?: true(the exact shapeactionRecordLoadSignalatpackages/runtime/src/action-execution.ts:1278emits) plus TSDoc.packages/spec/src/contracts/automation-context-record-load-denied.pin.test.ts— NEW type-level pin beside the family'sautomation-result-status.pin.test.ts(exact typetrue | undefined, additive positive control,@ts-expect-erroronfalse); insidetsconfig.test.json's program.- Generated products under
packages/spec/(api-surface / export-origins / declaration-map) andcontent/docs/references/**viacheck:generated --fix. - Hand-written docs listing the flow context's keys, as
scripts/docs-audit/affected-docs.mjslists them (pre-edit grep: no hand-written page undercontent/docsnamesAutomationContextoutside the generatedreferences/tree — the PR body states the delta). .changeset/*.md—@objectstack/specminor.
⛔ Not touched:packages/runtime/**,packages/services/**, objectui,content/docs/releases/**. The runtime half (dispatchFlowActionataction-execution.ts:1396and the REST/actionsdoor) is read-and-report only, for the PM'sdomain:clifollow-up card.
Generated by Claude Code
Generated by Claude Code
os-dev-report
{ "issue": 14244, "status": "done", "branch": "claude/issue-14244-automation-context-record-load-denied", "pr": "https://github.com/objectstack-ai/objectstack/pull/15143", "premise_still_valid": true, "summary": "Spec half of the inherited ruling, delivered as draft PR #15143 (head 6c14ba889, needs:contract-review hung and read back). AutomationContext (packages/spec/src/contracts/automation-service.ts) gains the additive key `recordLoadDenied?: true` — exactly the producer's shape (`actionRecordLoadSignal(load): { recordLoadDenied?: true }`, packages/runtime/src/action-execution.ts:1278 on 2cc46103, built in loadActionSubjectRecord :1245–:1270) — with TSDoc naming the meaning (caller-scope load did not deliver the row; `record` is the stamped id stub), who sets it (both doors, one producer), what a runAs:'system' flow guards on, what it does not claim, and that it is declared but NOT YET POPULATED on the flow face. A type-level pin file (true | undefined; identity with the producer's signal; additive positive control; @ts-expect-error on false; JSDoc-reading pin) sits inside tsconfig.test.json's program and is unledgered, so check:test-typecheck requires zero errors. content/docs/ui/actions.mdx gains a 'Flow actions' paragraph with the same honesty (affected-docs listed it plus releases/v15.mdx, which is release-owned and untouched); no hand-written page lists AutomationContext's keys. Changeset: @objectstack/spec minor; no ADR-0087 marker owed (the gate judges only declared-breaking changesets — `**BREAKING**` / `major` / `feat!:` — so none of the five marker spellings is used). Generated products unchanged (api-surface records export names, not interface members; references do not render contract interfaces): check:generated ✓ 15/15, --fix regenerated nothing. No semantic fork: the additive key is viable, dispatchFlowAction is not narrowed, runAs:'system' is not questioned. Read-and-report for the runtime follow-up card is in the PR body: dispatchFlowAction :791 receives `record` in its wiring and builds the execute() context at :820–:828 without any load signal; the MCP run_action arm (domains/mcp.ts:649 → invokeBusinessAction :1300) has `subject` at :1383 and drops it at :1407 while the handler face spreads actionRecordLoadSignal(subject) at :1435; the REST /actions door (domains/actions.ts:623 subject, :657 handler spread, :685 flow call) drops it the same way; seedFlowActionParams :628 copies every `record` key into params (:661), so the key leaks into params only if placed on `record` — carry it as a sibling context key; engine.execute (service-automation engine.ts:4082) seeds only record/previous/params into variables (:8176, :8108) and resolveRunContext :3943 spreads unknown context keys verbatim onto the run context, which suspended-run-store.ts:654/:704 persist as context_json — carried and persisted, read by nothing today; no test double or fixture constructs AutomationContext exhaustively (grep for keyof/Required/Omit/Pick/satisfies: 0 hits), confirmed by the downstream runtime typecheck. Deviations: merged origin/main (e37456eb0, one commit touching metadata-protocol and scripts/engine-double-contract.pinned.json — none of this PR's files) because dispatch-gates flagged a stale derivation input; the PM-side assignee was already set and untouched. Worktree removed without force after the PR opened; branch pushed clean.", "tests": "All at 6c14ba889 unless stated; every exit captured by redirect before any pipe; verdict lines quoted. spec build (os-verify-lock.sh -c 'pnpm --filter @objectstack/spec build'): 'check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present.' · 'VERDICT command-exit 0'. pin file (lock, vitest --maxWorkers=2 src/contracts/automation-context-record-load-denied.pin.test.ts): 'Test Files 1 passed (1)' · 'Tests 3 passed (3)' · 'VERDICT command-exit 0'. spec typecheck (lock, tsc --noEmit + check:scripts-typecheck + check:test-typecheck): 'check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; 54 file(s) / 261 error(s) / 145 pinned signature(s) held in test-typecheck-debt.json' · 'VERDICT command-exit 0'. check:generated: '✓ All 15 generated artifacts are up to date.' exit 0 on the first run, on --fix (nothing regenerated) and on the confirming run at the final head. eslint --no-inline-config over the two edited TS files: exit 0, no findings. Full `pnpm lint` in the foreground: exit 0, zero findings, 59s on the shared box (no narrowing to prove). check:doc-authoring: '✓ doc authoring guard: 393 files clean — no bare metadata literals.' exit 0. check:nul-bytes: 'check-nul-bytes: OK (scanned 8232 text file(s) -- 8232 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).' exit 0. Downstream @objectstack/runtime (lock, `pnpm turbo run typecheck --filter=@objectstack/runtime --concurrency=2`, closure built via ^build): '@objectstack/runtime:typecheck: cache miss, executing' · 'check:test-typecheck: OK — @objectstack/runtime's test layer compiles under packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned signature(s)' · 'Tasks: 30 successful, 30 total' · 'VERDICT command-exit 0 · held the lock 270s' — unaffected. @objectstack/service-automation: NOT MEASURED as a gate (no typecheck script: 'ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT', ledgered debt); raw `npx tsc --noEmit` under the lock: 3 errors, all 'src/nested-region-parity.test.ts: error TS2341: Property 'flows' is private', none naming recordLoadDenied/AutomationContext; base count not measured. Census after the last edit: 'check-system-context-census: OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read.' exit 0. Gate union: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` — 'gate list derived from the tree of objectstack-ai/objectstack at commit 6c14ba889', change set 4 paths vs merge base e37456eb0 (committed 4, working tree 0, untracked 0) → 81 commands: 79 exit 0; 2 NOT MEASURED in their own words — check:dual-build-cjs-loads exit 3 'PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. … ⛔ This is NOT a pass: nothing was measured' and check:type-check-debt exit 3 'PREREQUISITE NOT MET … ⛔ This is NOT a pass and NOT a finding: nothing was measured'. check:skill-examples first said 'Build first' (client-react dist absent) → client-react closure built under the lock → '✅ 257 prose examples type-check across 3 surface(s)' exit 0. Four spec gates (api-surface, browser-reachable-entries, dual-source-exports, entry-nameability) first said 'packages/spec/dist/**/*.d.ts is OLDER than packages/spec/src' because the reverse-verification touch left the source newer than dist (bytes identical, hash-proven) → spec rebuilt (VERDICT command-exit 0) → all four exit 0 ('@objectstack/spec public API surface + factory signatures unchanged ✓'). check:react-declaration-parity: 'Cannot run here' (needs objectui's manifest), unchanged posture. Reverse verification, one leg from the committed state (precondition git status --porcelain empty): delete only the `recordLoadDenied?: true;` line — landed on disk: grep -c 1 → 0, git diff --stat '1 file changed, 1 deletion(-)'; tsc --noEmit -p tsconfig.test.json → exit 2 with 7 new errors in the pin file: 'error TS2339: Property 'recordLoadDenied' does not exist on type 'AutomationContext'.' ×6 (lines 53, 57, 68, 69, 77, 79) and 'error TS2353: Object literal may only specify known properties, and 'recordLoadDenied' does not exist in type 'AutomationContext'.' (line 62); vitest → 'Tests 1 failed | 2 passed (3)', the JSDoc-reading pin failing with 'AssertionError: expected -1 to be greater than -1' (the two runtime-only tests stay green by design — esbuild strips types); direction observed: red, as predicted. Restore via trap `git checkout HEAD -- ABSOLUTE_PATH` exit 0, proven: git hash-object = 1bb555b762fbdd2a5db25e1f98d64acb419a93ac = HEAD blob; git diff HEAD empty; git status --porcelain empty; declaration count back to 1. affected-docs at the final head: 2 pages — content/docs/ui/actions.mdx (edited) and content/docs/releases/v15.mdx (release-owned, untouched). CI on the PR: in_progress at report time (draft PR just opened; not waited on per the standing rule).", "mcp_calls": "0 GitHub MCP calls — every GitHub read and write went through REST with GITHUB_TOKEN (issue, comments, claim comment, PR create, label add, label and body read-backs, report comment) and git; 1 Claude Code Remote MCP call (subscribe_pr_activity on PR #15143).", "open_questions": [ { "question": "For the runtime follow-up card (not this PR): once dispatchFlowAction spreads the producer's signal into the execute() context, should the flow face ALSO seed it as a run variable so a start-node CEL condition can guard on it, or stay context-only? Today engine.execute seeds nothing but record/previous/params into variables, so a context-only key is invisible to CEL until the engine reads it.", "options": [ "A — context-only, mirroring the handler face: the engine (or a runAs:'system'-aware guard in it) reads context.recordLoadDenied; no new variable vocabulary", "B — additionally seed a run variable (e.g. beside $runId / $flowName) so authors can write a start-node condition against it" ], "recommendation": "A first, because it mirrors the producer exactly and adds no authoring-surface vocabulary; B is a separate authoring-surface decision that can follow once A is measured. Neither blocks this card — the declared contract supports either." } ], "out_of_scope_findings": [] }Generated by Claude Code
Generated by Claude Code
Contract review (Clause ②) on PR #15143 — PASS · ACCEPT: comment 5535204682 (2026-09-04T03:23Z), by the
domain:specPM seat, sessionsession_0174WZTU6XcFcS7g2kykC53i, seat post #6017; tier fuse read 03:22Z,--pair 15143exit 0. Seat ruling recorded there (open to veto): the runtime half goes context-only first (option A), stated on thedomain:clicard this seat files at landing.needs:contract-reviewcleared on both carriers in this stroke. Landing at all-green on6c14ba88(17 checks running at 03:22Z); on MERGED this card closes viaFixes,pm:dispatchedis stripped, landing note here.
Generated by Claude Code
Landed — PR #15143 merged via the queue at 2026-09-04T04:33:40Z, merge commit
63cd4877(squash;origin/maintip25a59bd10at 04:34Z, the merge commit is its ancestor).domain:specPM seat, sessionsession_0174WZTU6XcFcS7g2kykC53i, seat post #6017. Contract review PASS · ACCEPT 5535204682; provenance 5535396576.Probed on
origin/mainat 04:36Z:packages/spec/src/contracts/automation-service.ts:63—recordLoadDenied?: true;onAutomationContext, with the producer's line cited in its TSDoc (:40); the pincontracts/automation-context-record-load-denied.pin.test.ts(exacttrue | undefined,:53; producer-shape equality,:57);content/docs/ui/actions.mdxcarries the flow-face paragraph; the changesetautomation-context-record-load-denied.mdis present. Runtime untouched, as declared: the flow door still passes no signal (action-execution.ts:791/:1407,domains/actions.ts:685).Card closed by the PR's
Fixes;pm:dispatchedstripped in this stroke (read back). Thecontracts/automation-service.tsreservation is released. Follow-up card filed at 2026-09-04T04:37Z: #15168 (domain:cliruntime half —dispatchFlowActionspreadsactionRecordLoadSignal(subject)into the flow context as a sibling key on both doors, pins for both doors, the "NOT YET POPULATED" sentence retired in the same stroke; context-only first —pm:queue,domain:*triage's).
Generated by Claude Code
- added a commit that references this issue
on Oct 7, 2026
Observed while implementing #14143 on
66ecc50a. ⛔ Unclaimed. Filed rather than fixed: #14143's dispatch order binds that card to the handler predicate, and this is one surface over.What #14143 fixed, and where it stops
#14143 adds
ctx.recordLoadDeniedto the script/body action face: when the dispatcher's caller-scope load of the subject row does not deliver it, the handler is told, instead of only receivingctx.record = { id: recordId }(the id being stamped on precisely because the load failed).Both dispatch doors now route their load through one producer,
loadActionSubjectRecord(packages/runtime/src/action-execution.ts). But the flow branch consumes the samerecordobject and carries no equivalent signal:packages/runtime/src/action-execution.ts— MCPrun_action, theaction.type === 'flow'branch passesrecordintodispatchFlowAction;packages/runtime/src/domains/actions.ts— REST/actions, same;dispatchFlowActionthen hands it toautomation.execute(action.target, { record, ... })as the run'sAutomationContext.record, and seedsparamsfrom the same object viaseedFlowActionParams.So a flow started by a row-scoped action on a row its invoker cannot read receives
record = { id: <recordId> }— shaped exactly like a legitimate record-less start, with nothing on the context distinguishing the two.Why it is a smaller claim than #14143, not the same one
A flow action is deliberately not system-elevated: the flow engine receives the caller's identity and honours
runAs(ADR-0049), so arunAs: 'user'flow re-derives the caller's scope on its own reads and the stub simply resolves to nothing. The exposure is narrower and conditional:runAs: 'system'on the target flow gets elevation plus a record stub the invoker could not read, and no context key to guard on;record.idbeing present reads the same always-true predicate action dispatcher stampsctx.record.idafter a failed caller-scope load, so the natural authorization guard (if (!ctx.record?.id) refuse()) is always true on a row the caller cannot read #14143 removed from the handler face.⛔ Not claimed
ctx.record.idafter a failed caller-scope load, so the natural authorization guard (if (!ctx.record?.id) refuse()) is always true on a row the caller cannot read #14143: this is a predicate/observability gap, not an incident report.runAs: 'system'is wrong — it is a declared, documented authoring decision.AutomationContextkey mirroringrecordLoadDenied, a documentation change onrunAs: 'system', or nothing at all is a design question for triage; action dispatcher stampsctx.record.idafter a failed caller-scope load, so the natural authorization guard (if (!ctx.record?.id) refuse()) is always true on a row the caller cannot read #14143 ruled only the handler face and ruled the narrowing direction out of that card's scope.Dedupe
One targeted
search_issues(repo-scoped REST search answered 403 for this seat, so the channel switch is declared here). 11 results, positive control holds — #14143 itself and therunAs/automation-elevation family (#14201, #14011, #5494, #7900, #3783, #4366) came back, so the query matched semantically rather than returning a silent zero. Nearest neighbours excluded:flow-update-readonly-when-fieldskipsrunAs:'system'flows entirely, but the conditional strip has noisSystemexemption #14201 (open) —flow-update-readonly-when-fieldskipsrunAs: 'system'flows in lint. SamerunAsvocabulary, different site (a build-time rule, not the dispatcher's context assembly)./automationrun-detail returns the triggering record's fields without that record's own FLS. Adjacent in spirit; different surface (a read API over stored runs, not the record handed to a run at start).create_recordunderrunAs:'system'inserts rows withowner_id/organization_id/created_byall NULL — records born untouchable even by admin #5494 / [automation/audit] runAs:'system' 流回写的审计行无归因(user_id/actor 双空),console 历史显示「未知用户」 #4366 / Approvals status mirror drops the acting user, forcing every downstream record-change flow torunAs:'system'#3783 — attribution of what arunAs: 'system'run writes. This is about what such a run is given.⛔ No duplicate.
Repro sketch
Declare a
type: 'flow'action on an object with OWDprivate, targeting a flow withrunAs: 'system'. Invoke it as a caller who cannot read the target row, with that row's id. The run'sAutomationContext.recordshould be observed as{ id: <recordId> }rather than absent — identical to a record-less start.