Repository navigation
fix(runtime): a flow start input declared a record id is loaded in the caller's own scope from the declared object at every door that starts the flow by name - #30628
Conversation
…w start door Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CbNMpDS92ifzhDw9vf
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CbNMpDS92ifzhDw9vf
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CbNMpDS92ifzhDw9vf
…iet on it Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CbNMpDS92ifzhDw9vf
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CbNMpDS92ifzhDw9vf
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CbNMpDS92ifzhDw9vf
📓 Docs Drift CheckThis PR changes 2 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 175 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 5084c5daed501623028e199372232c5d5ef0bf9d && git checkout 5084c5daed501623028e199372232c5d5ef0bf9d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aad1603cc2180b3eefa8cee41f5b895b8ee45909 469106e8f23c17cb8c314be413d44032d234c0ef && git checkout -B drift-repro aad1603cc2180b3eefa8cee41f5b895b8ee45909 && git merge --no-ff 469106e8f23c17cb8c314be413d44032d234c0ef
node scripts/docs-audit/affected-docs.mjs --json aad1603cc2180b3eefa8cee41f5b895b8ee45909
|
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL Contract-review-claim: session_01Utp6Cbvcmv8YWFhFJwzjKL · head 469106e · 2026-11-05T18:52Z |
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL Contract reviewServed-tier: 输入:卡 #29425 正文与全部 9 条评论(6580257050 裁定 A+C、6663860762 裁定 C0 撤回 C、A 维持;6664110414 认领;6666384440 开发报告);PR #30628 正文、11 个文件、 ① Derived judgments逐条列出 diff 蕴含的 accept-set / 公共面变化,各判对错:
Check-runs on ② Semver level
③ Boundary flags开发报告 6666384440
④ Design judgment
尝试打破:
无 must-fix。 Implemented-by: VERDICT: PASS |
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL 维护者速读
席位核对 — ACCEPT · domain:server#2 · head
|
Requested by Bill · project thread
domain:server#2 · os-bill · session_01Utp6CbNMpDS92ifzhDw9vf
维护者速读
objectName(声明“这个输入是某对象一条记录的 id”)后,凡是按名字启动流程的入口(触发路由新旧两种写法、REST 与 MCP 的流程型动作、声明的流程型端点),都会先用调用者自己的权限从声明的对象里加载这条记录:读不到或不存在答 404RECORD_NOT_FOUND,不是 id 答 400 并点名该输入,两种情况都不会创建运行。没写声明的输入保持原样。packages/runtime/src/flow-start-admission.ts的declaredReadsOf新增的声明读取(与可见读取取并集、跟随子流程);packages/spec/liveness/flow.json的variables.objectName行翻为 live;测试文件末尾新 describe。pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/flow-start-declared-record-reads.test.ts,预期 1 个文件 90 个用例全绿,其中 7 号 describe 每个入口下 7 个。Fixes #29425
Clause-②: yes (narrowing) — a start that ran before is now refused when it hands a declared record-id input an id the caller cannot read in the declared object, an id that names no record there, or a value that is not one id.
Metadata-protocol: none
Skills-docs: none — the published skills do not teach the start-door record rule or the
objectNameinput declaration:git grep -n -i -E "record reference|isInput: true, objectName|declared record|liveness-planned" -- 'skills/**'has one unrelated hit (skills/objectstack-ui/references/react-blocks.md:52, a redirect URL's record fields).What changes
The rule: a flow start input that declares it is the id of a record of a named object (
objectNameon the variable) is loaded, at every door that starts the flow by name, from the declared object in the caller's own scope before the run exists. An unreadable or nonexistent id is refused404 RECORD_NOT_FOUND; a value that is not one id is refused400naming the input. An undeclared input keeps the visible-read rule unchanged.One helper, no door edits. Every door already asks the shared check that PR fix(runtime): every flow start door judges a start input against the object the flow's definition reads it from #29365 introduced:
refuseUnreadableFlowStartRecords(action-execution.ts), which loads each input from every objectflowDeclaredRecordReads(flow-start-admission.ts) says the run reads it from.declaredReadsOfnow adds the declaration as one more read,{ object: objectName, many: false }. Door population, measured onmain2ffdb649and unchanged since fix(runtime): every flow start door judges a start input against the object the flow's definition reads it from #29365:POST /automation/:name/triggerand the legacyPOST /automation/trigger/:name, both throughrespondToFlowTriggerpackages/runtime/src/domains/automation.ts:2824type: 'flow'POST /actions/:object/:actionand MCPrun_action, both throughdispatchFlowActionpackages/runtime/src/action-execution.ts:2685type: 'flow'packages/runtime/src/endpoint-executor.ts:590Other
execute(flowName, …)callers are engine-internal (thesubflowandmapnodes, trigger bindings) or the verify harness, none of them a door.Declared and also read in plain sight against another object: both are judged. The declaration is added beside every visible read, never in place of one. If it replaced them, a run that reads object B under the same id would read a row nobody checked, which is the gap fix(runtime): every flow start door judges a start input against the object the flow's definition reads it from #29365 closed. So a caller must be able to read the row in both objects.
Followed into callees. A parent that hands its input on, unchanged, to a
subflowormapcallee whose input carries the declaration is judged by that declaration, through the same recursion as other callee reads.A
loopormapthat rebinds the name does not withdraw a declaration. The shadowing rule exists because a rebound name no longer holds the caller's value when a node reads it. The declaration is about the value the caller sent, so it stands.packages/spec(seat-authorised, minimal). TheobjectNameliveness row (packages/spec/liveness/flow.json›variables.objectName) flips fromplanned+authorWarntolive, with this reader as its evidence. The describe and docblock onFlowVariableSchema.objectNamedrop their planned sentences. Both the row and the docblock said to do this "in the commit that lands the reader". Left as they were,os validatewould go on warningliveness-planned-propertyon every flow that declares the key, and the describe would say that no door applies it. The regenerated artifacts arecontent/docs/references/{automation/flow,api/automation-api}.mdxandpackages/spec/liveness/state-counts/flow.md. No key, type or accepted value changes.Docs.
content/docs/automation/flows.mdx: the "Planned, no door applies this yet" callout under A start input that is a record id is replaced by the rule, and the404row under Run a flow via API names declared inputs.Changeset.
.changeset/29425-declared-record-input.md, patch for@objectstack/runtimeand@objectstack/spec, ADR-0087not-required (no-migration-prescription).Why the title carries no
!. No flow shipped in this repository declares the key today.git grepoverexamples/,packages/andapps/for an input variable literal that carriesobjectNamefinds only CHANGELOG example text. An input without the declaration answers exactly as before.Why
Ruling A on #29425 (6580257050, A half kept by 6663860762, which withdrew C): a flow start input may declare a record reference, and every door that starts the flow by name loads a declared input in the caller's scope before the run exists, with the refusals #29365 gives a visibly read input. The spec half (#29442) landed the key. This is the server half.
Tests
New top-level describe 7, a start input declared a record id is loaded from the declared object at every door, before the run exists, in
packages/runtime/src/flow-start-declared-record-reads.test.ts, the shared harness of #29365. It uses that file's row-scoped double, where each row is visible only to its owner. Per door (both trigger spellings, the endpoint, REST action, MCPrun_action):404 RECORD_NOT_FOUND,execute()never called;400namingcontractId, nothing loaded from the declared object, no run;Also pinned: the reading (
flowDeclaredRecordReads) for a declaration, a declaration beside a visible read of another object, a callee's declaration, and a loop that rebinds the name; the union at a door (readable in the declared object only →404; readable in both → run); a parent handing its input to a callee's declared input →404; the system principal → run, nothing loaded.Forced test edits (
packages/lint/src/lint-liveness-properties.test.ts, test-only). Two pins read the shipped ledger'splannedstate of this row. Flipping the row is what makes them false.params.widgetflip.params.widgetpin used this row as its control ("the walk still warns on a drilled planned row"). Both pins now take that control from the other shippedplanned+authorWarndrilled row, an app navigation entry'sdataSource.Acceptance notes
textthat holds an object, a list or a boolean is refused by the action's param contract (ADR-0104 D2),400 VALIDATION_ERRORnaming the param, before the start check is reached. The trigger routes and the endpoint answer400 VALIDATION_FAILEDfrom the start check. Either way the refusal is a 400 that names the input, nothing is loaded and no run starts. The pins assert the code per door.filter.id.$in) gets both readings, so no value satisfies it and every start is refused400. This is loud, not silent, and it is a lint candidate. It is not part of this rule.objectName: 'global'passes the spec's name pattern. The shared subject load treatsglobaland*as the object-less action key and loads nothing, so an input declared againstglobalis not judged. This is the same pre-existing behaviour as a visible read of an object namedglobal, and it is noted, not changed.Verification
Readings on head
469106e8f(after mergingorigin/main) unless stated; the ablations and the first runtime suite ran onc55e8047, before the merge.Pins, ablations
pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/flow-start-declared-record-reads.test.tsunder the verify lock:Tests 90 passed (90),VERDICT command-exit 0.scripts/ablation-replace.mjs --delete, on the line that adds the declaration (add(variable.name, { object: variable.objectName, many: false });). The anchor went from 1 to 0 and the blob from2b18eb885049to945bcd4535b1. Result:Tests 30 failed | 60 passed (90). Red: every door's readable / unreadable / nonexistent pin, the trigger and endpoint 400 pins, the union and the callee pins, and the four reading pins. Green as designed: the action-door 400 pins (the param contract answers first), every undeclared control, and the system principal. Restored: blob equals HEAD,git diff HEADempty.inputs.add(variable.name);made conditional on noobjectName). Blob2b18eb885049tocbe9b748c523. Result:Tests 2 failed | 88 passed (90), the two union pins. Restored, blob equals HEAD.src/by relative path, so nodistleg applies.Suites, typecheck, lint
pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2(before mergingmain):Test Files 547 passed (547),Tests 9911 passed | 19 skipped (9930).pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2:Test Files 195 passed (195),Tests 8103 passed (8103)on469106e8f; the declared-input pin file againTests 90 passed (90).pnpm --filter @objectstack/runtime typecheck,pnpm --filter @objectstack/lint typecheck: exit 0 (after building@objectstack/driver-turso, which a runtime kernel test imports and the closure build had not produced).eslint --no-inline-configon the five changed source and test files: exit 0. The repo-widepnpm lintis CI's.pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated: all 14 artifacts current aftergen:docsandgen:liveness-counts, and again after mergingmain.Full dogfood battery
469106e8f· closure buildturbo run build --filter='@objectstack/dogfood^...'exit 0, thenpnpm --filter @objectstack/dogfood testunder the verify lock:VERDICT command-exit 0·Test Files 420 passed | 3 skipped (423)·Tests 3496 passed | 36 skipped (3532)· no failures.Dispatch gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon469106e8f: 105 commands (11 paths, 417 changed lines, under the 3000-line threshold). Each ran with stdin from /dev/null and its exit code captured before any pipe: 105 exit 0.--ran:105 derived, 105 run, 0 NOT-MEASURED, 0 UNRUN.c55e8047, three had refused with exit 3 for prerequisites:check-plugin-teardown-shape --self-test(shallow clone; fixed by fetching its fixture commit),check:skill-examples(client-reactunbuilt) andcheck:dual-build-cjs-loads(workspace unbuilt). All three read exit 0 on469106e8fafter the closure build.Human Minimum
packages/specholds no runtime logic. (Describe text and the liveness ledger only.)pnpm lintpasses. (eslint on the changed files; the repo-wide run is CI's.)main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/. (The two reference pages are generator output.)Generated by Claude Code