Skip to content

fix(runtime): every flow start door judges a start input against the object the flow's definition reads it from - #29365

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-kkdbga
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-kkdbga

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Requested by Steve · project thread
Refs #29050

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:flow 定义里明确把某个启动输入当作「某个固定对象的记录 id」来读的地方(CRUD 节点的 filter.id / $eq / $in、screen 编辑表单的 recordId,以及经 subflow / map 传下去的输入),所有按名字启动 flow 的入口在启动前,都按调用者自己的权限从「flow 读的那个对象」加载这个输入;读不到回 404 RECORD_NOT_FOUND,值不是 id(或不是 id 列表)回 400 VALIDATION_FAILED 并点名该输入,不产生运行记录。REST action 入口的 body recordId 不是单个 id 时回 400 点名 recordId。
  • 为什么改:规则跟着 flow 定义里声明的读取走:定义把某个启动输入当作某对象的记录 id 读时,就从那个对象按调用者权限判断。只实现定义里已写明的读取,不改 spec,不加新键。
  • 风险与回滚:调用者能读的记录、定义里没有声明为 id 读取的输入(包括计算出来的值)、系统身份、action 入口的主记录字段和非 id 的 recordIdField 都不变。新增拒绝只针对定义里声明的 id 读取。回滚即撤掉本 PR。
  • 先看:packages/runtime/src/flow-start-admission.ts 的 flowDeclaredRecordReads;packages/runtime/src/action-execution.ts 的 refuseUnreadableFlowStartRecords 与 flowActionStartInputs;domains/actions.ts 的 recordId 形状检查。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/flow-start-declared-record-reads.test.ts,应看到 48 条全过。
  • 需要你做的:无,席位落地。

What changes

Flow start admission's third question (#29227, refuseUnreadableFlowStartRecords) now also asks it of the object the flow reads, wherever the flow's own definition declares that read.

The rule. Where a flow's definition reads a start input as the id of a record of a fixed object, every door that starts the flow by name loads that input in the caller's own scope from that object before the run exists:

  • a value the caller cannot read there, or an id that names nothing, is refused with the action door's own 404 RECORD_NOT_FOUND (one envelope for both);
  • the input must name one record by its id (a string or a number), or a list of records by their ids where the definition reads a list; any other value is refused 400 VALIDATION_FAILED naming the input, and nothing is loaded;
  • this holds whatever object the start names, or none, and whatever the flow runs as;
  • nothing runs on a refusal.

Where a definition declares such a read (flowDeclaredRecordReads, flow-start-admission.ts), across every region of the flow (collectFlowGraphs):

  • a get_record, update_record or delete_record node with a literal objectName whose filter.id or filter.id.$eq is the input (one id), or whose filter.id.$in is (a list);
  • a screen node with a literal objectName whose recordId is the input;
  • a subflow or map node with a literal flowName that hands the input (or, for a map iterating it, each item of it) to a callee input the callee reads so, followed up to 8 levels and never re-entering a flow already on the path.

"Is the input" means a CEL value whose whole source is the input's name, bare or through vars.. A computed value is not a declared read, and a name a loop or map node binds as its item or index variable is not read as a start input.

Which values each door judges.

  • Trigger routes (both spellings) and a declared type: 'flow' endpoint: every key of the built params bag (flowStartRecordKeys now also returns it as inputs).
  • The action door's flow branch, REST and MCP (flowActionStartInputs): each key of the caller's own params, and the routed record id under the row slots. The routed record keeps its subject load against the action's object, and is now also judged against the object the flow reads. Left out: the subject record's own fields, and the row slots of an action whose recordIdField names a non-id field.

The REST action route's routed record is one id (domains/actions.ts). A body recordId that is not a string or a number is refused 400 VALIDATION_FAILED naming recordId, before the declared param contract, the record load and dispatch, for every action type. Absent, null and '' stay the record-less call.

Unchanged. A start whose caller can read the record in the object the flow reads it from; an input the definition does not read as an id; the system principal; the #29227 checks on the start's named object, which run first as before.

Changeset: .changeset/29050-flow-declared-record-reads.md, a patch for @objectstack/runtime, rule-only.

Docs, in the changeset's words:

  • content/docs/automation/flows.mdx: the trigger route's 404 RECORD_NOT_FOUND row also states that a start input the flow's definition reads as the id of a record of a fixed object is loaded in the caller's own scope from that object, and must be one id or a list of ids (400 naming the input otherwise).
  • content/docs/ui/actions.mdx: a body recordId names one record by its id, a string or a number, or the route answers 400 VALIDATION_FAILED naming recordId.

Tests

  • New packages/runtime/src/flow-start-declared-record-reads.test.ts (48 tests), over a row-scoped data double with two objects:
    • the reading: each declaring place, the vars. spelling, nodes inside a loop body and a try region, subflow and map hand-offs, a self-calling flow, and what declares nothing (a non-input, a computed value, a non-id filter key, a non-literal object, a shadowed loop item, a service with no getFlow);
    • both trigger spellings: an input the caller cannot read in the object the flow reads is refused 404 with no run; the firing control starts the run;
    • a custom input: 404 with no run, the firing control, and 400 naming the input for an object, a list or a boolean, with nothing loaded;
    • a list input: all readable starts, one unreadable is 404, and one id or a list holding an object is 400;
    • reads declared through a subflow, a map and a screen edit form: 404 with no object named, including a user-run parent;
    • an unreadable id and an id that names nothing get one envelope;
    • a declared endpoint: 404 and the firing control;
    • the action door, REST and MCP: the routed record and a declared action param are judged against the object the flow reads, with firing controls, and the trigger route and the action door answer one envelope;
    • the REST action route: a body recordId that is an object, a list or a boolean is 400 naming recordId for a flow and a script action, with nothing loaded or run; a string and a number pass the shape check;
    • unchanged: a computed read, the system principal, an input the flow does not read as an id.
  • Forced test edits: none. The 50 existing runtime test files that drive the changed doors pass unchanged.

Acceptance notes

Verification

Head 1ffeb77c: the two docs pages on top of 5f06db07 (the merge of origin/main at 5780fa49 onto e92495c0). The code readings were taken on 5f06db07; 1ffeb77c changes no code.

Runtime package: typecheck, tests, lint
  • pnpm --filter @objectstack/runtime test through os-verify-lock.sh on head 5f06db07: Test Files 517 passed (517), Tests 8419 passed, 19 skipped (8438). VERDICT command-exit 0.
  • pnpm typecheck in packages/runtime (includes check:test-typecheck): exit 0; the test-typecheck debt ledger held unchanged.
  • vitest run src/flow-start-declared-record-reads.test.ts: Test Files 1 passed (1), Tests 48 passed (48).
  • Before the merge of origin/main, the 50 existing runtime test files that drive the trigger routes, the endpoint and the action door (every test file naming handleActions, dispatchFlowAction, invokeBusinessAction, flowStartRecordKeys or refuseUnreadableFlowStartRecords): Test Files 50 passed (50), Tests 1326 passed (1326), with no edit.
  • eslint packages/runtime --no-inline-config (the root pnpm lint command, scoped): exit 0, no output.

Each pin goes red with its check removed (seven ablations, each restored to the HEAD blob).

Dispatch gates
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on head 1ffeb77c derived 92 commands (the 62 of 5f06db07 plus 30 docs families). Each ran with < /dev/null: 92 exit 0.
  • dispatch-gates.mjs --repo objectstack-ai/objectstack --ran <file>: 92 derived famil(ies) accounted for, 92 run, 0 NOT-MEASURED (a DERIVED zero). Changed lines 967 (+937 / −30) against the 3000 threshold.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ADHkmRp2YLTpRc6Vfw6TD3

…ds them from

A start input that a flow's own definition reads as the id of a record
of a fixed object (a CRUD node's filter.id, $eq or $in, a screen edit
form's recordId, handed on through subflow and map inputs) is now loaded
in the caller's own scope from that object at every door that starts the
flow by name, and must be one id, or a list of ids where it is read as a
list. A value the caller cannot read there is refused 404
RECORD_NOT_FOUND, and any other shape 400 VALIDATION_FAILED naming the
input, before the run exists.

The REST action route also refuses a body recordId that is not one id
with 400 naming recordId, before the param contract, the load and
dispatch.

Claude-Session: https://claude.ai/code/session_01ADHkmRp2YLTpRc6Vfw6TD3
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

47 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 57cde8cb3da5970f2c3f6512569cacdb9b2b06a8.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (literal, 55 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 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 57cde8cb3da5970f2c3f6512569cacdb9b2b06a8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 730933e93d1120dfae25395b901b70730d7249ef — the merge of head 1ffeb77cc18ed85fa628ece0052e2fdfd6258ae8 into base 57cde8cb3da5970f2c3f6512569cacdb9b2b06a8, 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 730933e93d1120dfae25395b901b70730d7249ef && git checkout 730933e93d1120dfae25395b901b70730d7249ef
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 57cde8cb3da5970f2c3f6512569cacdb9b2b06a8 1ffeb77cc18ed85fa628ece0052e2fdfd6258ae8 && git checkout -B drift-repro 57cde8cb3da5970f2c3f6512569cacdb9b2b06a8 && git merge --no-ff 1ffeb77cc18ed85fa628ece0052e2fdfd6258ae8

node scripts/docs-audit/affected-docs.mjs --json 57cde8cb3da5970f2c3f6512569cacdb9b2b06a8

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

The flow trigger route's 404 RECORD_NOT_FOUND row now says that a start
input the flow's definition reads as a record id of a fixed object is
loaded in the caller's scope from that object, and must be one id or a
list of ids. The action page says a body recordId must be one id.

Claude-Session: https://claude.ai/code/session_01ADHkmRp2YLTpRc6Vfw6TD3
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

维护者速读

  • 改了什么:flow 定义里明确把某个启动输入当作「某个固定对象的记录 id」来读的地方(CRUD 节点的 filter.id / $eq / $in、screen 编辑表单的 recordId,以及经 subflow / map 传下去的输入),所有按名字启动 flow 的入口在启动前,都按调用者自己的权限从「flow 读的那个对象」加载这个输入;读不到回 404 RECORD_NOT_FOUND,值不是 id(或不是 id 列表)回 400 VALIDATION_FAILED 并点名该输入,不产生运行记录。REST action 入口的 body recordId 不是单个 id 时回 400 点名 recordId。
  • 为什么改:规则跟着 flow 定义里声明的读取走:定义把某个启动输入当作某对象的记录 id 读时,就从那个对象按调用者权限判断。只实现定义里已写明的读取,不改 spec,不加新键。
  • 风险与回滚:调用者能读的记录、定义里没有声明为 id 读取的输入(包括计算出来的值)、系统身份、action 入口的主记录字段和非 id 的 recordIdField 都不变。新增拒绝只针对定义里声明的 id 读取。回滚即撤掉本 PR。
  • 先看:packages/runtime/src/flow-start-admission.ts 的 flowDeclaredRecordReads;packages/runtime/src/action-execution.ts 的 refuseUnreadableFlowStartRecords 与 flowActionStartInputs;domains/actions.ts 的 recordId 形状检查。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/flow-start-declared-record-reads.test.ts,应看到 48 条全过。
  • 需要你做的:无,席位落地。

席位意见:与 diff 相符。只判断 flow 定义里写明的 id 读取,不加新键、不改 spec;#29227 的检查原样先跑。正文与 changeset 只写规则(规则 C)。

席位核对

ACCEPT · domain:server seat 5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 · 2026-11-02T13:33Z · head 1ffeb77cc1 · Refs #29050 (follow-up under ruling C, claim 6577844505)

  1. Shape: draft against main, Refs #29050, with no closing keyword. patch changeset for @objectstack/runtime, rule-only. Clause-②: no, Metadata-protocol: none. Not governed: 967 changed lines, 8 files.
  2. Against the order: the rule stated in the body and the changeset is implemented as stated, from the reads a flow definition declares only, at every door that starts a flow by name, before the run exists. Read in the diff.
  3. Accepted deviations: two docs pages, rule-only, added by claim revision 6577844505. The PR body was written by this seat from the dev's r1.
  4. Gates: the dev reports 92/92 derived gates exit 0 on this head (0 NOT-MEASURED), the runtime suite at 517 files / 8419 tests, and typecheck and eslint exit 0. Each new pin goes red with its check removed. Check-runs on this head: Lint & Repo Gates, TypeScript Type Check and Test Core green; the seat readies the PR only when no check is red or running.
  5. Out of scope: handled under ruling C, not on this PR.
  6. Landing: pr_ready + automerge_enable through the relay.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 13:35
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit dfbe34d Nov 2, 2026
55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-kkdbga branch November 2, 2026 13:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant