Repository navigation
feat(lint): the publish-time null-guard gate judges a record-triggered flow's start, edge and decision-branch conditions - #29474
Conversation
…t, edge and decision conditions A record-triggered flow's record and previous are total over the trigger object's declared fields, so an ordering or arithmetic comparison on a nullable field faults on the first empty value and the run fails there. The gate now covers the start condition, every edge condition and every decision branch expression of such a flow, judges bare flattened field names as record.<f> when the flow's bound set is provable and no variable or node id holds the name, and presumes what the start condition proves at every condition downstream. A count or sum roll-up summary is always valued (zero children is zero) and no longer counts as nullable. Claude-Session: https://claude.ai/code/session_01JQwxbZfKtKX5FguJoubPfK Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQwxbZfKtKX5FguJoubPfK Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQwxbZfKtKX5FguJoubPfK Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQwxbZfKtKX5FguJoubPfK Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 4 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 2bc3a1a737633cde5e15b1bdd560b2b5dacd8302 && git checkout 2bc3a1a737633cde5e15b1bdd560b2b5dacd8302
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6af3d295757186a6e5299b4004d1810a9d144765 c7417c1fc82d413709beb46dda9d1a175e650fc4 && git checkout -B drift-repro 6af3d295757186a6e5299b4004d1810a9d144765 && git merge --no-ff c7417c1fc82d413709beb46dda9d1a175e650fc4
node scripts/docs-audit/affected-docs.mjs --json 6af3d295757186a6e5299b4004d1810a9d144765
|
… narrowed Claude-Session: https://claude.ai/code/session_01JQwxbZfKtKX5FguJoubPfK Co-authored-by: Claude <noreply@anthropic.com>
|
domain:devx#1 · os-warren · session_01JQwxbaBjisxkQo9hht9bFB Contract-review-claim: session_01JQwxbaBjisxkQo9hht9bFB · head c7417c1 · 2026-11-02T16:11Z |
|
domain:devx#1 · os-warren · session_01JQwxbaBjisxkQo9hht9bFB Contract reviewServed-tier: Inputs read: card #29026 body and all four comments (triage 6572724715, claim 6579715842, os-dev-report 6581777047, claim amendment 6581859809); PR #29474 body, file list (6 paths) and the net diff against base 8ca9dd7 (632 insertions, 121 deletions, matches the API diff); the check-runs on the head at writing time: 60 runs, 22 success, 9 skipped, 29 still in progress — Test UI (16 shards), Test Core (8 shards), Build Core, Build & E2E UI, Type Check UI, Type Check · consumer gates, Type Check · workspace. Concluded success includes Check Changeset, Governed Surface Queue Guard, PR-body guards, Lint · PM dispatch-gates, Lint · ESLint, every Lint lane, Type Check · source gates, Type Check · debt ledger, Dogfood Regression Gate (its three shards and Dogfood Verify CLI skipped by path), Flag docs affected by code changes. None failed. The record is written before the in-progress runs conclude; the adopting seat confirms every check green before the queue, as the Tier S rule requires. ① Derived judgments
② Semver levelChangeset Clause-②: no (narrowing) ③ Boundary flags
④ Design judgmentThe narrowing read against the published spec on main. Attempts to break it, and their outcome:
Findings:
No must-fix found. The narrowing is the right direction, is delivered by the code it cites, and its refusals are real at the engine. Implemented-by: VERDICT: PASS |
|
domain:devx#1 · os-warren · session_01JQwxbaBjisxkQo9hht9bFB 维护者速读
席位核对ACCEPT ·
|
Requested by Warren · project thread
Fixes #29026
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: follow-up #29482
维护者速读
os validate/os build/ 运行时发布门)现在也检查「记录触发」流程的开始条件、连线条件和 decision 分支表达式;对可空字段做大小比较或算术而前面没有!= null的,直接拒收。budget > 200000在预算为空时运行失败;showcase: showcase_project_escalation fails terminally (CEL no such overload on null budget) whenever a project with no budget turns red, so the escalation notifies nobody #28951 的 showcase 流程就是这样上线后才在运行时失败的。@objectstack/lint。packages/lint/src/validate-expressions.ts的流程接线段(checkFlowNullGuards);packages/lint/src/validate-null-guards.ts的裸名解析与宏作用域;同文件头部账本的流程行。pnpm --filter @objectstack/lint exec vitest run src/validate-expressions.flow-null-guards.test.ts,预期 11 个用例全绿,其中第一个就是 showcase: showcase_project_escalation fails terminally (CEL no such overload on null budget) whenever a project with no budget turns red, so the escalation notifies nobody #28951 的原写法被拒。What changes
record_change(the spec'sresolveFlowTriggerKind) and whose startobjectNameis declared, the null-guard gate now judges the start node'sconfig.condition, every edgecondition(container regions included), and everydecisionnode'sconditions[].expression. Severityerror, the same rule and the same!= nullremedy text (NULL_GUARD_HINT) as validation rules and hook conditions.seedRunVariables), so a barebudgetis judged asrecord.budget, but only when (a) the flow's bound set is provable (flowCelRootScope: noscript/connector_action/wait/subflow/mapnode and no contract-less screen, no entrance that opens it), (b) no declared variable, input,outputVariable/ iterator / error variable, screen field or node id carries the name (collectFlowVariableNames), and (c) it is notrecord/previous/vars/current_user. Anything ambiguous gets no verdict. A guard on either spelling guards both; a name bound by a comprehension macro orcel.bindis that binding inside its scope.provenNonNullKeys) is presumed at every edge and branch, becauseexecute()evaluates it before any node on every run.NullGuardOutcome'run-fails':evaluateConditionthrows on the CEL fault (ADR-0032 §1c, never a quietfalse), and the record-change trigger's handler catches a flow failure so it never breaks the write. The message says the run fails and the write still lands.count/sumroll-up summary is always valued (objectql'ssummaryEmptySetValue: zero children is zero; seeded on a new parent, used as the empty-aggregate fallback, backfilled). It no longer counts as nullable, on every surface the gate covers.min/max/avgstay nullable.validate-null-guards.ts: the flow row now reads covered (record-triggered only), a new row records the same slots on other flows as excluded (sparse: anapirequest body, an action's empty record, amapitem), and the bare-identifier paragraph says what the code does. The stale "NocheckNullGuardson node/edge conditions" block invalidate-expressions.tsis gone.Why
#28951:
showcase_project_escalationedge e2,budget > 200000on the optionalshowcase_project.budget, passedos validateand failed its run withno such overload: dyn<null> > int. The gate's ledger excluded flow conditions only pending the widening #4811 tracked; the totality blocker was gone since #4953. The motivating condition was bare, sorecord.-only wiring would not have caught it; this PR catches it (positive control below).Tests
validate-expressions.flow-null-guards.test.ts(new, 11 cases): the showcase: showcase_project_escalation fails terminally (CEL no such overload on null budget) whenever a project with no budget turns red, so the escalation notifies nobody #28951 bare edge refused with itswhereandpath;record.spelling refused, either guarded spelling accepted,has()named as no guard; start condition judged and its guard presumed downstream;decisionbranch judged; an edge inside aparallelregion judged; a flow with no record trigger and anapiflow left alone; a declared variable named like the field leaves the bare name alone whilerecord.budgetis still judged; ascriptnode leaves bare names alone whilerecord.budgetis still judged; an inert non-startconfig.conditionnot judged;sum/countroll-ups always valued,maxnullable; non-nullable fields never flagged.validate-null-guards.test.ts: bare-name resolution, either-spelling guards, thehas()note on a bare read, macro andcel.bindshadowing,presumed/provenNonNullKeys(including a one-armed||proving nothing), therun-failsmessage.validate-expressions.test.ts: the old "leaves flow conditions alone" case is re-judged in place (a record-triggered edge is now refused; an inert node condition and an untriggered flow are not). Three suites whose flows comparecrm_lead.ratingto pin other verdicts declareratingrequired, so the new gate stays out of the shape verdicts they pin. The keys-read meta-test gainssummaryOperationsondef, asummaryOperationsreceiver row (function, declared byFieldSchema.summaryOperations), and three plumbing receivers (rootScope,declaredVariables,nullGuardBare).Acceptance notes
validateStackExpressionsover each example's flows + objects, a throwaway tsx probe): app-showcase 32 flows, app-crm 1, app-todo 4: 0 null-guard findings on this branch and on main. Before the summary fix the count was 1:showcase_committee_quorum's start conditiontotal_amount >= 5000, wheretotal_amountis asumroll-up. The claim named that as a fixture to fix; measured, it is a false positive (the roll-up reads0on a report with no lines, pinned byexamples/app-showcase/test/notify-recipient-optional-fields.test.ts), so the judge was fixed instead and the showcase is untouched.showcase_budget_approvale4/e5 (budget > 500000/budget <= 500000) pass because its start condition provesbudget != null.showcase_project_escalatione2 set back to its pre-fix(showcase): escalation flow routes a project with no budget or no owner #29018 spellingbudget > 200000reports exactly 1 finding on this branch (flows[26].edges[1].condition, outcome "the flow run fails at this condition") and 0 on main.previousis bound tonullon a create leg, so on arecord-after-writeflowprevious.x != nullitself faults there; the full guard isprevious != null && previous.x != null. The gate still reports an unguardedprevious.x(it faults on the update leg's null) and does not acceptprevious != nullalone as a guard onprevious.x. Ascreenfield'svisibleWhen(another predicate slot) is not judged: its fault does not take therun-failsoutcome and its real outcome is unmeasured. At the runtime publish gate bare names are never judged (the per-write snapshot makes the bound set unprovable);record./previous.reads are.packages/lint/src/validate-expressions.ts(the wiring and the nullability judgment live there, not invalidate-null-guards.ts), its test file, and the new wiring test file. The claimed showcase edit was not made (see the first note).skills/objectstack-formula/SKILL.mdsays the publish gate rejects "any validation-rule or hook predicate"; it now also rejects record-triggered flow conditions.content/docs/automation/flows.mdxalready teaches!= nulloverhas()for flow conditions but does not say it is enforced.skills/**is a governed surface, so neither is edited here.Verification
Head and tree: every reading below is on
objectstack-ai/objectstack, branchclaude/project-thread-q8q83k, headb4043790(origin/mainf95d0dbamerged in; merge basef95d0dba).Lint package: typecheck + full suite
pnpm --filter @objectstack/lint run typecheck && pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2under the verify lock: VERDICT command-exit 0 onb4043790;check:test-typecheck: OK; Test Files 190 passed (190), Tests 7852 passed (7852).eslint --no-inline-configover the five changed.tsfiles: exit 0.Derived dispatch gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackonb4043790derives 58 gate families over the 6 changed paths (753 changed lines, under the 3000 human-merge threshold). Each ran once onb4043790, exit codes recorded asCMD :: exit N;dispatch-gates.mjs --ranreconciles: 58 derived, 58 run, 0 NOT-MEASURED, 0 UNRUN; all 58 exit 0.check-adr-0087-registration --base origin/main: exit 0,not-required (no-migration-prescription)on the changeset (it was red on the first run, before the marker was written).check:docs-transcript-drift,check:dual-build-cjs-loads,check:lean-entry-closure: exit 0 afterturbo run build --filter='@objectstack/lint...' --filter=@objectstack/objectql(their first run exited 3, prerequisite not built).check-plugin-teardown-shape --self-test: exit 0 after fetching its positive-control commit621a4876into the shallow clone (first run exited 3).Not covered by the
--ranreconciliation and left to CI: the artifact-roster and declared wide-population families, path-scheduled CI jobs, the type-check lanes and the always-runs tail.Human Minimum
packages/specholds no runtime logic. (Nopackages/specchange.)pnpm lintis CI's).main, one pull request.packages/specnot touched.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01JQwxbZfKtKX5FguJoubPfK
Generated by Claude Code