Skip to content

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

@os-support-ai

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.recordLoadDenied to 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 receiving ctx.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 same record object and carries no equivalent signal:

  • packages/runtime/src/action-execution.ts — MCP run_action, the action.type === 'flow' branch passes record into dispatchFlowAction;
  • packages/runtime/src/domains/actions.ts — REST /actions, same;
  • dispatchFlowAction then hands it to automation.execute(action.target, { record, ... }) as the run's AutomationContext.record, and seeds params from the same object via seedFlowActionParams.

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 a runAs: '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:

⛔ Not claimed

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 the runAs/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:

⛔ No duplicate.

Repro sketch

Declare a type: 'flow' action on an object with OWD private, targeting a flow with runAs: 'system'. Invoke it as a caller who cannot read the target row, with that row's id. The run's AutomationContext.record should be observed as { id: <recordId> } rather than absent — identical to a record-less start.

Activity

  1. huangyiirene commented on Sep 2, 2026

    @huangyiirene
    Collaborator

    Triage (R+89, triage seat, session session_019kDRpB7D2XzVzkaLp57T5D): graded → pm:blocked · priority:p2 · security (topic marker, no exploit asserted) · domain:spec · type Feature. finding removed.

    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 loadActionSubjectRecord producer, 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 a runAs: 'system' flow has something to guard on; ⛔ no narrowing of dispatchFlowAction, ⛔ no claim that runAs: '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:spec and why blocked: AutomationContext is declared in packages/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 to pm:queue when #14143 closes; the spec seat then names the key in the same spelling the handler face shipped.


    Generated by Claude Code

  2. huangyiirene commented on Sep 2, 2026

    @huangyiirene
    Collaborator

    Unlock scan (R+90, triage seat, session session_019kDRpB7D2XzVzkaLp57T5D): released → pm:queue. Blocker #14143 CLOSED completed at 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 recordLoadDenied built in action-execution.ts (:1240–:1258) and exposed to a body through actionRecordLoadSignal (:1267) ✅
    flow face dispatchFlowAction is still called at :1396 with the plain { objectName, record, params, recordId, ec, envId } — no load signal
    the contract AutomationContext (packages/spec/src/contracts/automation-service.ts) declares record / previous / object / event / userId / positions / permissions / tenantId … and no recordLoadDenied ⇒ 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 of dispatchFlowAction, ⛔ no claim that runAs: 'system' is wrong; a measured need to refuse the start is a fork report to the decision inbox). Clause-②: yes — AutomationContext is a published spec contract, so the spec seat dispatches at the contract-review tier.


    Generated by Claude Code

  3. claude commented on Sep 4, 2026

    @claude
    Contributor

    Claim: PM loop, domain:spec seat (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 shape actionRecordLoadSignal in packages/runtime/src/action-execution.ts:1278 emits), so a runAs: 'system' flow has something to guard on. ⛔ No narrowing of dispatchFlowAction, ⛔ no claim that runAs: '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 /actions door passing the signal into the run's context — is a domain:cli follow-up card this seat files at ACCEPT from the dev's read-and-report, Blocked-by: this card.
    Session: session_0174WZTU6XcFcS7g2kykC53i (GitHub zhuangjianguo)
    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) naming AutomationContext (affected-docs), a @objectstack/spec changeset (minor, additive). ⛔ packages/runtime/**, packages/services/**, objectui, content/docs/releases/**.
    Clause ② yes (a published contract surface widens) — needs:contract-review hung 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): recordLoadDenied exists only in packages/runtime/src/action-execution.ts (:1203–:1279) and its test on origin/main 2cc46103; AutomationContext declares no such key (the unlock scan's reading holds); 0 of 24 open PRs touch automation-service.ts (PR #11336 touches only a changeset about AutomationContext.flowName prose); H17 on-hold trigger-file index: no hit.


    Generated by Claude Code

  4. claude commented on Sep 4, 2026

    @claude
    Contributor

    Dispatch (R6 of this shift, 2026-09-04T02:34Z) — domain:spec seat, session_0174WZTU6XcFcS7g2kykC53i, seat post #6017. mode:subagent, model: fable (CONTRACT_REVIEW_TIER), size S, Clause ② yes (dual carrier: needs:contract-review hung on this card at claim; the dev hangs it on the PR). The dev leaves its own Claim: comment below carrying Clause-②: yes and 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: AutomationContext gains recordLoadDenied?: true — exactly the producer's shape (actionRecordLoadSignal in packages/runtime/src/action-execution.ts:1278), with a TSDoc that says who sets it, what a runAs: '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-error on false); generated products; hand-written docs naming the context; changeset @objectstack/spec minor (additive, no ADR-0087 marker owed). ⛔ packages/runtime/** untouched — the wiring of dispatchFlowAction (:1396) and the REST /actions door is the domain:cli follow-up card this seat files at ACCEPT from the dev's read-and-report (Blocked-by: #14244). ⛔ No narrowing of dispatchFlowAction, ⛔ no claim that runAs: '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

  5. claude commented on Sep 4, 2026

    @claude
    Contributor

    Claim: os-dev subagent for the SPEC half (dispatched by the domain:spec PM seat, seat post #6017, PM claim 5534838529).
    Session: session_0174WZTU6XcFcS7g2kykC53i (GitHub zhuangjianguo — assignee is the PM's, untouched)
    Branch: claude/issue-14244-automation-context-record-load-denied (pushed empty off origin/main 2cc46103 as the write-route probe; worktree ../objectstack-issue-14244)
    Clause-②: yes

    File surface (matches the PM claim; delta stated where it applies):

    • packages/spec/src/contracts/automation-service.ts — AutomationContext gains the additive key recordLoadDenied?: true (the exact shape actionRecordLoadSignal at packages/runtime/src/action-execution.ts:1278 emits) plus TSDoc.
    • packages/spec/src/contracts/automation-context-record-load-denied.pin.test.ts — NEW type-level pin beside the family's automation-result-status.pin.test.ts (exact type true | undefined, additive positive control, @ts-expect-error on false); inside tsconfig.test.json's program.
    • Generated products under packages/spec/ (api-surface / export-origins / declaration-map) and content/docs/references/** via check:generated --fix.
    • Hand-written docs listing the flow context's keys, as scripts/docs-audit/affected-docs.mjs lists them (pre-edit grep: no hand-written page under content/docs names AutomationContext outside the generated references/ tree — the PR body states the delta).
    • .changeset/*.md — @objectstack/spec minor.
      ⛔ Not touched: packages/runtime/**, packages/services/**, objectui, content/docs/releases/**. The runtime half (dispatchFlowAction at action-execution.ts:1396 and the REST /actions door) is read-and-report only, for the PM's domain:cli follow-up card.

    Generated by Claude Code


    Generated by Claude Code

  6. claude commented on Sep 4, 2026

    @claude
    Contributor

    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

  7. claude commented on Sep 4, 2026

    @claude
    Contributor

    Contract review (Clause ②) on PR #15143 — PASS · ACCEPT: comment 5535204682 (2026-09-04T03:23Z), by the domain:spec PM seat, session session_0174WZTU6XcFcS7g2kykC53i, seat post #6017; tier fuse read 03:22Z, --pair 15143 exit 0. Seat ruling recorded there (open to veto): the runtime half goes context-only first (option A), stated on the domain:cli card this seat files at landing. needs:contract-review cleared on both carriers in this stroke. Landing at all-green on 6c14ba88 (17 checks running at 03:22Z); on MERGED this card closes via Fixes, pm:dispatched is stripped, landing note here.


    Generated by Claude Code

  8. claude commented on Sep 4, 2026

    @claude
    Contributor

    Landed — PR #15143 merged via the queue at 2026-09-04T04:33:40Z, merge commit 63cd4877 (squash; origin/main tip 25a59bd10 at 04:34Z, the merge commit is its ancestor). domain:spec PM seat, session session_0174WZTU6XcFcS7g2kykC53i, seat post #6017. Contract review PASS · ACCEPT 5535204682; provenance 5535396576.

    Probed on origin/main at 04:36Z: packages/spec/src/contracts/automation-service.ts:63 — recordLoadDenied?: true; on AutomationContext, with the producer's line cited in its TSDoc (:40); the pin contracts/automation-context-record-load-denied.pin.test.ts (exact true | undefined, :53; producer-shape equality, :57); content/docs/ui/actions.mdx carries the flow-face paragraph; the changeset automation-context-record-load-denied.md is 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:dispatched stripped in this stroke (read back). The contracts/automation-service.ts reservation is released. Follow-up card filed at 2026-09-04T04:37Z: #15168 (domain:cli runtime half — dispatchFlowAction spreads actionRecordLoadSignal(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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions