Skip to content

feat(lint): the publish-time null-guard gate judges a record-triggered flow's start, edge and decision-branch conditions - #29474

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

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-q8q83k

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Warren · project thread
Fixes #29026

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: follow-up #29482

维护者速读

What changes

  • Covered: on a flow whose trigger kind is record_change (the spec's resolveFlowTriggerKind) and whose start objectName is declared, the null-guard gate now judges the start node's config.condition, every edge condition (container regions included), and every decision node's conditions[].expression. Severity error, the same rule and the same != null remedy text (NULL_GUARD_HINT) as validation rules and hook conditions.
  • Bare names: the run flattens the record's fields to top level (seedRunVariables), so a bare budget is judged as record.budget, but only when (a) the flow's bound set is provable (flowCelRootScope: no script / connector_action / wait / subflow / map node 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 not record / previous / vars / current_user. Anything ambiguous gets no verdict. A guard on either spelling guards both; a name bound by a comprehension macro or cel.bind is that binding inside its scope.
  • Start condition dominates: what the start condition proves non-null (provenNonNullKeys) is presumed at every edge and branch, because execute() evaluates it before any node on every run.
  • Outcome clause read off the engine: new NullGuardOutcome 'run-fails': evaluateCondition throws on the CEL fault (ADR-0032 §1c, never a quiet false), 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.
  • Nullability fix (fallout): a count / sum roll-up summary is always valued (objectql's summaryEmptySetValue: 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 / avg stay nullable.
  • Ledger in 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: an api request body, an action's empty record, a map item), and the bare-identifier paragraph says what the code does. The stale "No checkNullGuards on node/edge conditions" block in validate-expressions.ts is gone.

Why

#28951: showcase_project_escalation edge e2, budget > 200000 on the optional showcase_project.budget, passed os validate and failed its run with no 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, so record.-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 its where and path; record. spelling refused, either guarded spelling accepted, has() named as no guard; start condition judged and its guard presumed downstream; decision branch judged; an edge inside a parallel region judged; a flow with no record trigger and an api flow left alone; a declared variable named like the field leaves the bare name alone while record.budget is still judged; a script node leaves bare names alone while record.budget is still judged; an inert non-start config.condition not judged; sum / count roll-ups always valued, max nullable; non-nullable fields never flagged.
  • validate-null-guards.test.ts: bare-name resolution, either-spelling guards, the has() note on a bare read, macro and cel.bind shadowing, presumed / provenNonNullKeys (including a one-armed || proving nothing), the run-fails message.
  • 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 compare crm_lead.rating to pin other verdicts declare rating required, so the new gate stays out of the shape verdicts they pin. The keys-read meta-test gains summaryOperations on def, a summaryOperations receiver row (function, declared by FieldSchema.summaryOperations), and three plumbing receivers (rootScope, declaredVariables, nullGuardBare).

Acceptance notes

  • Fixture fallout, measured with the real gate (validateStackExpressions over 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 condition total_amount >= 5000, where total_amount is a sum roll-up. The claim named that as a fixture to fix; measured, it is a false positive (the roll-up reads 0 on a report with no lines, pinned by examples/app-showcase/test/notify-recipient-optional-fields.test.ts), so the judge was fixed instead and the showcase is untouched. showcase_budget_approval e4/e5 (budget > 500000 / budget <= 500000) pass because its start condition proves budget != null.
  • Positive control: the same probe with showcase_project_escalation e2 set back to its pre-fix(showcase): escalation flow routes a project with no budget or no owner #29018 spelling budget > 200000 reports exactly 1 finding on this branch (flows[26].edges[1].condition, outcome "the flow run fails at this condition") and 0 on main.
  • Not reached, on purpose: previous is bound to null on a create leg, so on a record-after-write flow previous.x != null itself faults there; the full guard is previous != null && previous.x != null. The gate still reports an unguarded previous.x (it faults on the update leg's null) and does not accept previous != null alone as a guard on previous.x. A screen field's visibleWhen (another predicate slot) is not judged: its fault does not take the run-fails outcome 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.
  • File surface beyond the claim: packages/lint/src/validate-expressions.ts (the wiring and the nullability judgment live there, not in validate-null-guards.ts), its test file, and the new wiring test file. The claimed showcase edit was not made (see the first note).
  • Skills/docs follow-up owed: skills/objectstack-formula/SKILL.md says the publish gate rejects "any validation-rule or hook predicate"; it now also rejects record-triggered flow conditions. content/docs/automation/flows.mdx already teaches != null over has() 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, branch claude/project-thread-q8q83k, head b4043790 (origin/main f95d0dba merged in; merge base f95d0dba).

Lint package: typecheck + full suite

pnpm --filter @objectstack/lint run typecheck && pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 under the verify lock: VERDICT command-exit 0 on b4043790; check:test-typecheck: OK; Test Files 190 passed (190), Tests 7852 passed (7852). eslint --no-inline-config over the five changed .ts files: exit 0.

Derived dispatch gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on b4043790 derives 58 gate families over the 6 changed paths (753 changed lines, under the 3000 human-merge threshold). Each ran once on b4043790, exit codes recorded as CMD :: exit N; dispatch-gates.mjs --ran reconciles: 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 after turbo 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 commit 621a4876 into the shallow clone (first run exited 3).

Not covered by the --ran reconciliation 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

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No packages/spec change.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; eslint passes on the changed files (the repo-wide pnpm lint is CI's).
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block is the repo's required Chinese summary).

🤖 Generated with Claude Code

https://claude.ai/code/session_01JQwxbZfKtKX5FguJoubPfK


Generated by Claude Code

…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>
@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/lint, touching 21 documentable anchor(s).

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/declarative-endpoints.mdx (via record_change (literal, a string literal in runStackExpressionPasses))
  • content/docs/api/plugin-endpoints.mdx (via record_change (literal, a string literal in runStackExpressionPasses))
  • content/docs/automation/approvals.mdx (via record_change (literal, a string literal in runStackExpressionPasses))
  • content/docs/automation/flows.mdx (via record_change (literal, a string literal in runStackExpressionPasses))
  • content/docs/automation/hooks.mdx (via record_change (literal, a string literal in runStackExpressionPasses))
  • content/docs/automation/workflows.mdx (via record_change (literal, a string literal in runStackExpressionPasses))
  • content/docs/concepts/architecture.mdx (via record_change (literal, a string literal in runStackExpressionPasses))
  • content/docs/getting-started/common-patterns.mdx (via record_change (literal, a string literal in runStackExpressionPasses))
  • content/docs/kernel/services-checklist.mdx (via record_change (literal, a string literal in runStackExpressionPasses))
  • content/docs/permissions/capabilities.mdx (via record_change (literal, a string literal in runStackExpressionPasses))
  • content/docs/permissions/system-context.mdx (via record_change (literal, a string literal in runStackExpressionPasses))
  • content/docs/protocol/objectql/schema.mdx (via record_change (literal, a string literal in runStackExpressionPasses))
  • content/docs/ui/actions.mdx (via record_change (literal, a string literal in runStackExpressionPasses))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx (via record_change (literal, a string literal in runStackExpressionPasses))
  • content/docs/releases/v17/17-0.mdx (via record_change (literal, a string literal in runStackExpressionPasses))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: current_user (literal, 42 pages)
  • 5 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 — 4 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 6af3d295757186a6e5299b4004d1810a9d144765 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2bc3a1a737633cde5e15b1bdd560b2b5dacd8302 — the merge of head c7417c1fc82d413709beb46dda9d1a175e650fc4 into base 6af3d295757186a6e5299b4004d1810a9d144765, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · os-warren · session_01JQwxbaBjisxkQo9hht9bFB

Contract-review-claim: session_01JQwxbaBjisxkQo9hht9bFB · head c7417c1 · 2026-11-02T16:11Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · os-warren · session_01JQwxbaBjisxkQo9hht9bFB

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c7417c1fc82d413709beb46dda9d1a175e650fc4
Local-runs: none
Scope: packages/lint/src/validate-null-guards.ts, packages/lint/src/validate-null-guards.test.ts, packages/lint/src/validate-expressions.ts, packages/lint/src/validate-expressions.test.ts, packages/lint/src/validate-expressions.flow-null-guards.test.ts + .changeset/29026-flow-condition-null-guard.md (read for ②), packages/services/service-automation/src/dag-traversal.ts, packages/services/service-automation/src/engine.ts, packages/triggers/trigger-record-change/src/record-change-trigger.ts, packages/objectql/src/summary-aggregate.ts, packages/objectql/src/engine.ts, packages/spec/src/automation/flow-trigger-kind.ts, packages/spec/src/data/field.zod.ts, packages/lint/src/flow-cel-root-scope.ts, packages/lint/src/flow-variable-scope.ts, packages/lint/src/index.ts, examples/app-showcase/src/automation/flows/index.ts, examples/app-showcase/test/notify-recipient-optional-fields.test.ts, content/docs/automation/flows.mdx, skills/objectstack-formula/SKILL.md

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

  1. Accept-set change, the narrowing itself: a flow whose resolveFlowTriggerKind is record_change with a declared start objectName now has its start config.condition, every edge condition (container regions included) and every decision conditions[].expression judged by findUnguardedNullableOperands, severity error, outcome run-fails. RIGHT. Read against the engine on main: evaluateCondition (dag-traversal.ts) throws on a non-ok CEL result (ADR-0032 §1c, never a quiet false); execute() records a loud flow failure; the record-change trigger handler catches and swallows so the write lands (record-change-trigger.ts, the error-isolation catch). buildContext runs both record and previous through materializeDeclaredFields whenever the prior row is in hand or the event is an insert, so the binding is total and != null is the sound prescription, exactly as on validation rules and hook conditions. The message's outcome clause ("the flow run fails at this condition: the write that fired it still lands") matches the code.
  2. Accept-set change, bare names: a bare identifier is judged as record.name only when flowCelRootScope(...).provable holds, the name is a nullable field of the trigger object, and it is neither in collectFlowVariableNames (declared variables, inputs, outputVariable, iterator, error variables, screen fields, assignment targets, node ids) nor in record / previous / vars / current_user. RIGHT and conservative: seedRunVariables seeds declared variables first, binds record, then flattens the record's fields only where no variable holds the name, then binds previous; celScope layers vars / current_user over. A guard on either spelling guards both (both resolve to the key record.name), which is how the run reads them. One safe-direction blind spot noted, not a finding: a declared variable with no default and not supplied as input is never seeded, so the bare field IS flattened into that name at run time while the gate leaves it unjudged — a missed refusal, never a false one, and the validate-expressions has no flow leg for bare identifiers — a bare field reference in a flow condition passes objectstack validate clean #14089 shadow warning already covers that shape.
  3. Accept-set change, start-condition presumption: provenNonNullKeys(startCondition) is presumed at every edge and branch. RIGHT: execute() evaluates the start condition before any node on every entrance (engine.ts, the trigger-condition gate); executeWithoutRetry does not re-evaluate it but is entered only after that gate passed on the same variable map. truthGuards unions over &&, intersects over || and ?:, so a one-armed || proves nothing (pinned).
  4. Accept-set change, macro and cel.bind scoping: a comprehension-bound or cel.bind-bound name is not the field inside its body; the receiver and the initialiser stay outside. RIGHT (mirrors COMPREHENSION_MACROS in flow-cel-root-scope.ts).
  5. Accept-set WIDENING on every surface the gate covers (validation rules, hook conditions, requiredWhen, and the new flow slots): a summary field with summaryOperations.function count or sum is no longer nullable. Judged right against objectql on main: summaryEmptySetValue is 0 for count / sum, initializeSummaryFields seeds it on insert before beforeInsert hooks, aggregateSummaryValue falls back to it on an empty aggregate, and summaryNullIsBackfillable declares a stored NULL on those functions a hole. min / max / avg stay nullable. Measured on the showcase: showcase_committee_quorum (total_amount at-least 5000, a sum) is now not flagged, and the cited pin exists (notify-recipient-optional-fields.test.ts line 370 expects total_amount 0). One precision gap carried to ④ (should-fix 2): the always-valued claim presumes the one-off summary count/sum 存量 NULL 行的一次性回填 —— #5749 方案 1 落地后的遗留半边(原地升级的库仍漏行) #6063 backfill ran on pre-fix(objectql): 新建父行时把 count/sum 型 summary 汇总初始化为 0 (#5749) #6013 parents.
  6. Surfaces left out, each named right: a non-start node's config.condition (inert; flow-inert-node-condition reports it), flows with no record trigger (api body, action record, map item — nothing materializes them), a screen field's visibleWhen (different fault outcome, unmeasured), time-relative sweeps (not record_change; swept rows not shown to be materialized — conservative). At the runtime publish gate perWriteSnapshotEntrance opens the scope, so bare names are never judged there and record. / previous. reads are.
  7. Public surface of @objectstack/lint (index.ts): the exported type NullGuardOptions gains two optional keys, bareFields and presumed — additive, every existing caller unchanged. provenNonNullKeys and the widened NullGuardOutcome (run-fails) are module exports not re-exported from the package index, so not public. NullGuardFinding is unchanged (the resolver's key stays internal). RIGHT for a minor.
  8. Tests re-judged in place: the old "leaves flow conditions alone" case becomes the covered verdict with the inert-node and untriggered controls kept; three suites make crm_lead.rating required so their shape verdicts stay pinned; the keys-read meta-test declares summaryOperations on def, a summaryOperations.function receiver, and three plumbing receivers. Each is the minimal edit that keeps the pinned assertion meaningful. RIGHT.

② Semver level

Changeset .changeset/29026-flow-condition-null-guard.md: @objectstack/lint: minor. The diff moves one published package, @objectstack/lint, and nothing else. PR body and changeset both carry, line-initial, Clause-②: no (narrowing) and Metadata-protocol: narrowed. Per the level axis in check-changeset-no-major.mjs: a declared kind takes at least minor on a moved package — satisfied; narrowed beside a line carrying the (narrowing) arm — satisfied (the arm was added at c7417c1 after the claim amendment 6581859809; the earlier commits lacked it and would have been the contradiction). The kind is right: a flow an author could publish on main is refused on this head, and the count / sum widening is a second kind that narrowed outranks. A (narrowing) arm is BREAKING: the changeset carries the FROM → TO fix (budget != null && in front of the comparison, or the guard in the start condition) and the ADR-0087 disposition marker not-required (no-migration-prescription), a category the gate lists; no authorable key is removed, so no tombstone is owed. Check Changeset and Lint · PM dispatch-gates concluded success on the head. Level judged right.

Clause-②: no (narrowing)

③ Boundary flags

  • open_questions: empty in the os-dev-report. None to answer.
  • Deviation 1 (file surface widened to validate-expressions.ts and the new wiring test): answered by the claim amendment 6581859809; the file list matches the amended claim exactly.
  • Deviation 2 (the claimed showcase_committee_quorum fixture fix not made): answered by measurement read in the tree — total_amount is a sum roll-up (flows/index.ts line 1937; the test pin at line 370), so the judge was fixed instead of the example. Right call under rule 12 (fix the producer of the false verdict, not the metadata).
  • Deviation 3 (origin/main f95d0db merged in): fine; the diff against base is clean.
  • Out-of-scope class c (skills and docs say the gate rejects validation-rule or hook predicates only): the report says "noted, not filed", but the PR body names follow-up Teach the record-triggered flow null-guard gate in the formula skill and the flows page #29482, which exists and is open ("Teach the record-triggered flow null-guard gate in the formula skill and the flows page"). Answered. skills/** is governed and is correctly untouched here.
  • Out-of-scope class b (previous.x on a create leg): noted, not filed. Escalated as should-fix 3 below — it is a metadata-authoring trap in the gate's own prescription, which rule 10 files rather than notes.
  • dogfood_battery: n/a — no ! in the PR title and no (narrowing) arm: stale at this head. The arm was declared at c7417c1, after the report, and os-dev.md says the full battery runs once on the final head when the Clause-②: line carries (narrowing). Escalated as should-fix 4; not a FAIL because the CI Dogfood Regression Gate concluded success on the head (shards path-skipped) and the dev measured the battery's catch class directly: the real judge over app-showcase (32 flows), app-crm (1) and app-todo (4) reports 0 null-guard findings, with 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 spelling as a positive control reporting exactly 1.
  • PR body Verification cites head b404379; the head is c7417c1, a changeset-only delta (the (narrowing) arm). Noted; the check-runs on c7417c1 are the verdict.

④ Design judgment

The narrowing read against the published spec on main. content/docs/automation/flows.mdx teaches that conditions are strict CEL, that has() is not the escape hatch it looks, and that an absent or null operand aborts the run; skills/objectstack-formula/SKILL.md teaches != null guards and previous != null && on insert legs. The spec declares no capability that an unguarded ordering on a nullable field in a flow condition works — it declares the opposite, and the engine delivers the opposite (evaluateCondition throws). So this PR makes the spec's own refusal real at publish time; it takes no declared capability away at the consumer, and nothing is narrowed in a consumer in place of a producer fix (rule 12: reject at authoring). The remedy text is the one the spec teaches, and the ledger's has() measurement matches materializeDeclaredFields. The ledger row, the removed stale block in validate-expressions.ts and the gate are one change, read as one; the ledger's binding column still states facts about the surface, not verdicts, as the readonlyWhen row needs.

Attempts to break it, and their outcome:

  • Retry path skipping the start gate: executeWithoutRetry does not re-evaluate the start condition, but is reached only from execute() after the gate held on the same seeded map. Holds.
  • Delete events: buildContext builds the record from the prior row and materializes it when the prior row is in hand. Holds.
  • A record-triggered flow also launched by an action handing an empty record: the ledger says this explicitly and names the guard safe on both bindings (has(record.x) && record.x != null), which truthGuards on && accepts. Holds, by declared choice.
  • A script / connector_action / wait / subflow / map node, or a contract-less screen: the bound set is unprovable, bare names unjudged, record. still judged (pinned). Holds.
  • Pre-fix(objectql): 新建父行时把 count/sum 型 summary 汇总初始化为 0 (#5749) #6013 parents: see should-fix 2.
  • The two doors disagreeing: see should-fix 1.

Findings:

  • should-fix 1 — Build and runtime publish doors can disagree in the runtime-stricter direction. At os validate a start condition budget != null (bare) proves record.budget and an edge comparing record.budget with an ordering operator passes; at the runtime publish gate the bare set is empty (perWriteSnapshotEntrance opens the scope), the presumption is not derived, and the same edge is refused. Mixed spelling is unusual, and deriving the presumption from a bare guard with an unprovable bound set would be unsound, so the choice is defensible; but a flow that os validate accepts and the publish gate refuses is a trap the ledger does not record. Record it in the ledger's flow row and the changeset's "At the runtime publish gate" sentence, or make the runtime gate stand down on the presumption-dependent finding the way the entrance judgment stands down.
  • should-fix 2 — isAlwaysValuedSummary's doc comment says the engine "backfills a stored NULL with it", which reads as automatic. On main the backfill is the one-off os migrate summary-nulls (summary-backfill.ts, summary count/sum 存量 NULL 行的一次性回填 —— #5749 方案 1 落地后的遗留半边(原地升级的库仍漏行) #6063); initializeSummaryFields is create-time only and "existing rows are untouched". A pre-fix(objectql): 新建父行时把 count/sum 型 summary 汇总初始化为 0 (#5749) #6013 parent never backfilled still reads NULL, and on a validation rule that fault is fail-closed on the write. The widening is sound under the engine's contract (a NULL count / sum is declared a hole), but the comment and the changeset bullet "Fewer findings elsewhere" should say the judgment presumes the migration ran.
  • should-fix 3 — previous.x on a create leg. The gate reports an unguarded previous.x and prescribes != null; on a record-after-write / insert flow previous is bound to null (seedRunVariables), so previous.x != null itself faults and the full guard is previous != null && previous.x != null. The prescription is incomplete, not wrong (the unguarded read faulted there too), but the dev's own classification is a metadata-authoring trap, which rule 10 files as a card rather than notes. File it; the hint could name previous != null && for a previous. operand.
  • should-fix 4 — The dogfood_battery line is stale on the final head: the (narrowing) arm makes the battery owed under os-dev.md. Run pnpm --filter @objectstack/dogfood test on c7417c1 and amend the report, or have the adopting seat record that the example-app probe (0 findings; positive control 1) plus the green CI Dogfood Regression Gate stand as the measurement.

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: claude/project-thread-q8q83k
Reviewed-by: session_01JQwxbaBjisxkQo9hht9bFB

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · os-warren · session_01JQwxbaBjisxkQo9hht9bFB

维护者速读

席位核对

ACCEPT · domain:devx#1 · head c7417c1fc82d413709beb46dda9d1a175e650fc4 · 2026-11-02T16:39Z

  1. Shape: six files: packages/lint/src/validate-null-guards.ts, validate-expressions.ts, their tests, the new validate-expressions.flow-null-guards.test.ts, and .changeset/29026-flow-condition-null-guard.md (minor, Clause-②: no (narrowing), Metadata-protocol: narrowed, ADR-0087 marker). Inside the claim as amended (6581859809: the wiring lives in validate-expressions.ts; no example-app edit, showcase_committee_quorum measured a false positive and answered by the count / sum roll-up rule).
  2. Premise, measured: the ledger row "flow / edge condition" on main was excluded only by the "widening tracked separately (null-guard 闸门(#4763)只覆盖了校验规则与 hook 条件 —— action / flow 条件两面待定,formula 面待判 #4811)" clause; the run reads every declared field (materializeDeclaredFields) and evaluateCondition throws on the CEL fault while the record-change trigger swallows it so the write lands. Read off the code by the contract reviewer (record 6582253324 ①) and by the dev (report 6581777047). Positive control: 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 spelling gives 1 finding on the branch, 0 on main.
  3. Gates: local 58 derived / 58 run / all exit 0; @objectstack/lint full suite 190/190 files, 7852/7852 tests (report). CI on this head: 54 success, 9 expected skips, none red. The full dogfood battery (should-fix 4, follow-up report 6582613426) was red on 4 cases in flow-runas.dogfood.test.ts and declarative-endpoint-anonymous-guest.dogfood.test.ts; the dev reproduced them with packages/lint/src reset to the merge base. The seat read them as the closed main-red main red: Dogfood Regression Gate fails on main since #29365 (dfbe34d8) — flow-runas and declarative-endpoint-anonymous-guest dogfood files #29426, fixed on main by test(dogfood): re-point the runAs and anonymous-guest flow pins to flow start admission's record rule #29451 (6af3d295), which is not in this branch's base (f95d0dba); the merge queue tests against a main that has it. Not this PR's.
  4. Accepted deviations: the file-surface widening and the dropped example edit, both in claim amendment 6581859809. Fixes / Clause-② / Metadata-protocol / Skills-docs sit above ## 维护者速读 (os-dev order); the PR body's Verification cites b4043790, and c7417c1f differs from it by the changeset line only.
  5. Out of scope: filed Teach the record-triggered flow null-guard gate in the formula skill and the flows page #29482 (Skills-docs: follow-up #29482: skills/objectstack-formula/SKILL.md and content/docs/automation/flows.mdx teach the new flow slots) and lint null-guard gate: three accuracy follow-ups from the contract review of the flow-condition widening #29491 (the contract review's should-fix 1–3: build vs runtime door divergence, the isAlwaysValuedSummary backfill wording, the previous.x create-leg hint). Both rung to triage (6581907284, 6582302613).
  6. Landing: not governed (packages/lint/**), under 3000 changed lines. The narrowing owes a same-head contract-review-tier PASS: record 6582253324 on c7417c1f (Served-tier CONTRACT_REVIEW_TIER, Local-runs none), claim 6582019844. Seat lands it: pr_ready + automerge_enable now, every check on this head green or an expected skip.

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

1 participant